diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 000000000000..bc7cbef7732b --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +codecov: + branch: master + ci: + - drone.nextcloud.com + notify: + after_n_builds: 2 + +coverage: + precision: 2 + round: down + range: "70...100" + status: + project: + default: + threshold: 0.5 + +comment: + layout: "header, diff, changes, uncovered, tree" + behavior: default + require_changes: true + after_n_builds: 2 + +github_checks: + annotations: false + +ignore: + - "app/src/main/res/values*/*" + diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000000..152027db4f00 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,19 @@ +FROM ubuntu:noble@sha256:84e77dee7d1bc93fb029a45e3c6cb9d8aa4831ccfcc7103d36e876938d28895b + +ARG DEBIAN_FRONTEND=noninteractive +ENV ANDROID_HOME=/usr/lib/android-sdk + +RUN apt-get update -y +RUN apt-get install -y unzip wget openjdk-21-jdk vim + +RUN wget https://dl.google.com/android/repository/commandlinetools-linux-6858069_latest.zip -O /tmp/commandlinetools.zip +RUN cd /tmp && unzip commandlinetools.zip +RUN mkdir -p /usr/lib/android-sdk/cmdline-tools/ +RUN cd /tmp/ && mv cmdline-tools/ latest/ && mv latest/ /usr/lib/android-sdk/cmdline-tools/ +RUN mkdir /usr/lib/android-sdk/licenses/ +RUN chmod -R 755 /usr/lib/android-sdk/ +RUN mkdir -p "$HOME/.gradle" && \ + echo "org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" > "$HOME/.gradle/gradle.properties" && \ + echo "org.gradle.caching=true" >> "$HOME/.gradle/gradle.properties" && \ + echo "org.gradle.parallel=true" >> "$HOME/.gradle/gradle.properties" && \ + echo "org.gradle.configureondemand=true" >> "$HOME/.gradle/gradle.properties" diff --git a/.devcontainer/Dockerfile.license b/.devcontainer/Dockerfile.license new file mode 100644 index 000000000000..d078384126a1 --- /dev/null +++ b/.devcontainer/Dockerfile.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 000000000000..60bcf908c5b7 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,9 @@ + +# Instructions + +1. Start a DevContainer either on GitHub Codespaces or locally in VSCode. +2. Accept all licenses by running `yes | /usr/lib/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses`. +3. You can now build the app using `./gradlew clean build`. diff --git a/.devcontainer/devcontainer.env b/.devcontainer/devcontainer.env new file mode 100644 index 000000000000..369163cf4fcf --- /dev/null +++ b/.devcontainer/devcontainer.env @@ -0,0 +1,3 @@ +ANDROID_HOME=/usr/lib/android-sdk +JAVA_OPTS="-Xmx8192M" +GRADLE_OPTS="-Dorg.gradle.daemon=true" diff --git a/.devcontainer/devcontainer.env.license b/.devcontainer/devcontainer.env.license new file mode 100644 index 000000000000..d078384126a1 --- /dev/null +++ b/.devcontainer/devcontainer.env.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000000..a13d6f9ee4e8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "NextcloudAndroid", + "dockerFile": "Dockerfile", +} diff --git a/.devcontainer/devcontainer.json.license b/.devcontainer/devcontainer.json.license new file mode 100644 index 000000000000..d078384126a1 --- /dev/null +++ b/.devcontainer/devcontainer.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only diff --git a/.drone.yml b/.drone.yml index 5e2447f26950..4da327c6a57b 100644 --- a/.drone.yml +++ b/.drone.yml @@ -1,64 +1,192 @@ -pipeline: - test: - image: nextcloudci/android:android-32 - commands: - # uncomment gplay for Gplay, Modified only - - sh -c "if [ '$FLAVOUR' != 'Generic' ]; then sed -i '/.*com.google.*/s/^.*\\/\\///g' build.gradle; fi" +--- +kind: pipeline +type: docker +name: tests-stable - # - echo no | android create avd --force -n test -t $ANDROID_TARGET --abi $ANDROID_ABI -c 20M - # - emulator -avd test -no-window & - # - ./wait_for_emulator.sh +# SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - # build app and assemble APK, in debug mode - - sh -c "if [ '$FLAVOUR' != 'Lint' ]; then ./gradlew assemble${FLAVOUR}; fi" - # run all the instrumented tests of app module - DISABLED until we get an stable setup for Espresso in Travis - # - ./gradlew connectedDebugAndroidTest --info +steps: + - name: gplay + image: ghcr.io/nextcloud/continuous-integration-android16:latest + privileged: true + environment: + LOG_USERNAME: + from_secret: LOG_USERNAME + LOG_PASSWORD: + from_secret: LOG_PASSWORD + GIT_USERNAME: + from_secret: GIT_USERNAME + GITHUB_TOKEN: + from_secret: GIT_TOKEN + commands: + - scripts/checkIfRunDrone.sh $DRONE_PULL_REQUEST || exit 0 + - emulator -avd android -no-snapshot -gpu swiftshader_indirect -no-window -no-audio -skin 500x833 & + - sed -i s'#false#true#'g app/src/main/res/values/setup.xml + - ./gradlew assembleGplayDebugAndroidTest + - scripts/wait_for_emulator.sh + - ./gradlew installGplayDebugAndroidTest + - scripts/wait_for_server.sh "server" + - scripts/deleteOldComments.sh "stable" "IT" $DRONE_PULL_REQUEST + - ./gradlew createGplayDebugCoverageReport -Pcoverage -Pandroid.testInstrumentationRunnerArguments.notAnnotation=com.owncloud.android.utils.ScreenshotTest || scripts/uploadReport.sh $LOG_USERNAME $LOG_PASSWORD $DRONE_BUILD_NUMBER "stable" "IT" $DRONE_PULL_REQUEST - # install app, then assemble and install instrumented tests of app module - # - ./gradlew :install${FLAVOUR}Debug - # - ./gradlew :install${FLAVOUR}DebugAndroidTest +services: + - name: server + image: ghcr.io/nextcloud/continuous-integration-shallow-server:latest # also change in updateScreenshots.sh + environment: + EVAL: true + SERVER_VERSION: 'stable30' + commands: + - BRANCH="$SERVER_VERSION" /usr/local/bin/initnc.sh + - echo 127.0.0.1 server >> /etc/hosts + - rm /etc/apt/sources.list.d/php.list + - apt-get update && apt-get install -y composer + - su www-data -c "OC_PASS=user1 php /var/www/html/occ user:add --password-from-env --display-name='User One' user1" + - su www-data -c "OC_PASS=user2 php /var/www/html/occ user:add --password-from-env --display-name='User Two' user2" + - su www-data -c "OC_PASS=user3 php /var/www/html/occ user:add --password-from-env --display-name='User Three' user3" + - su www-data -c "php /var/www/html/occ user:setting user2 files quota 1G" + - su www-data -c "php /var/www/html/occ group:add users" + - su www-data -c "php /var/www/html/occ group:adduser users user1" + - su www-data -c "php /var/www/html/occ group:adduser users user2" + - su www-data -c "git clone --depth 1 -b $SERVER_VERSION https://github.com/nextcloud/activity.git /var/www/html/apps/activity/" + - su www-data -c "php /var/www/html/occ app:enable activity" + - su www-data -c "git clone --depth 1 -b $SERVER_VERSION https://github.com/nextcloud/text.git /var/www/html/apps/text/" + - su www-data -c "php /var/www/html/occ app:enable text" + - su www-data -c "git clone --depth 1 -b $SERVER_VERSION https://github.com/nextcloud/end_to_end_encryption.git /var/www/html/apps/end_to_end_encryption/" + - su www-data -c "php /var/www/html/occ app:enable end_to_end_encryption" + - su www-data -c "git clone --depth 1 -b $SERVER_VERSION https://github.com/nextcloud/photos.git /var/www/html/apps/photos/" + - su www-data -c "cd /var/www/html/apps/photos; composer install --no-dev" + - su www-data -c "php /var/www/html/occ app:enable -f photos" + - su www-data -c "php /var/www/html/occ config:system:set ratelimit.protection.enabled --value false --type bool" + - /usr/local/bin/run.sh - # run sample instrumented unit test - # TODO fails because test runner is not available - #- adb shell am instrument -w -e debug false -e class com.owncloud.android.datamodel.OCFileUnitTest com.owncloud.android.test/android.support.test.runner.AndroidJUnitRunner +trigger: + branch: + - master + - stable-* + event: + - push + - pull_request +--- +kind: pipeline +type: docker +name: tests-master +steps: + - name: gplay + image: ghcr.io/nextcloud/continuous-integration-android16:latest + privileged: true environment: - - ANDROID_TARGET=android-32 - - ANDROID_ABI=armeabi-v7a - - LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/:/opt/android-sdk-linux/tools/lib64/gles_mesa/ + LOG_USERNAME: + from_secret: LOG_USERNAME + LOG_PASSWORD: + from_secret: LOG_PASSWORD + GIT_USERNAME: + from_secret: GIT_USERNAME + GITHUB_TOKEN: + from_secret: GIT_TOKEN + commands: + - scripts/checkIfRunDrone.sh $DRONE_PULL_REQUEST || exit 0 + - emulator -avd android -no-snapshot -gpu swiftshader_indirect -no-window -no-audio -skin 500x833 & + - sed -i s'#false#true#'g app/src/main/res/values/setup.xml + - scripts/runCombinedTest.sh $DRONE_PULL_REQUEST $LOG_USERNAME $LOG_PASSWORD $DRONE_BUILD_NUMBER - lint: - image: nextcloudci/android:android-32 +services: + - name: server + image: ghcr.io/nextcloud/continuous-integration-shallow-server:latest # also change in updateScreenshots.sh + environment: + EVAL: true commands: - # needs gplay - - sed -i '/.*com.google.*/s/^.*\\/\\///g' build.gradle - - export BRANCH=$(scripts/lint/getBranchName.sh $GIT_USERNAME $GIT_TOKEN $DRONE_PULL_REQUEST) - - scripts/lint/lint-up-wrapper.sh $GIT_USERNAME $GIT_TOKEN $BRANCH $LOG_USERNAME $LOG_PASSWORD $DRONE_BUILD_NUMBER - secrets: [ GIT_USERNAME, GIT_TOKEN, LOG_USERNAME, LOG_PASSWORD ] - when: - matrix: - FLAVOUR: Lint - - notify: - image: drillster/drone-email - host: $EMAIL_HOST + - /usr/local/bin/initnc.sh + - echo 127.0.0.1 server >> /etc/hosts + - rm /etc/apt/sources.list.d/php.list + - apt-get update && apt-get install -y composer + - su www-data -c "OC_PASS=user1 php /var/www/html/occ user:add --password-from-env --display-name='User One' user1" + - su www-data -c "OC_PASS=user2 php /var/www/html/occ user:add --password-from-env --display-name='User Two' user2" + - su www-data -c "OC_PASS=user3 php /var/www/html/occ user:add --password-from-env --display-name='User Three' user3" + - su www-data -c "php /var/www/html/occ user:setting user2 files quota 1G" + - su www-data -c "php /var/www/html/occ group:add users" + - su www-data -c "php /var/www/html/occ group:adduser users user1" + - su www-data -c "php /var/www/html/occ group:adduser users user2" + - su www-data -c "git clone --depth 1 -b master https://github.com/nextcloud/activity.git /var/www/html/apps/activity/" + - su www-data -c "php /var/www/html/occ app:enable activity" + - su www-data -c "git clone --depth 1 -b main https://github.com/nextcloud/text.git /var/www/html/apps/text/" + - su www-data -c "php /var/www/html/occ app:enable text" + - su www-data -c "git clone --depth 1 -b master https://github.com/nextcloud/end_to_end_encryption/ /var/www/html/apps/end_to_end_encryption/" + - su www-data -c "php /var/www/html/occ app:enable end_to_end_encryption" + - su www-data -c "git clone --depth 1 https://github.com/nextcloud/photos.git /var/www/html/apps/photos/" + - su www-data -c "cd /var/www/html/apps/photos; composer install --no-dev" + - su www-data -c "php /var/www/html/occ app:enable -f photos" + - su www-data -c "php /var/www/html/occ config:system:set ratelimit.protection.enabled --value false --type bool" + - /usr/local/bin/run.sh + +trigger: + branch: + - master + - stable-* + event: + - push + - pull_request + +--- +kind: pipeline +type: docker +name: allScreenshots + +steps: + - name: runAllScreenshots + image: ghcr.io/nextcloud/continuous-integration-android16:latest + privileged: true + environment: + GIT_USERNAME: + from_secret: GIT_USERNAME + GITHUB_TOKEN: + from_secret: GIT_TOKEN + LOG_USERNAME: + from_secret: LOG_USERNAME + LOG_PASSWORD: + from_secret: LOG_PASSWORD + commands: + - emulator -avd android -no-snapshot -gpu swiftshader_indirect -no-window -no-audio -skin 500x833 & + - sed -i s'#false#true#'g app/src/main/res/values/setup.xml + - sed -i s'#showOnlyFailingTestsInReports = ciBuild#showOnlyFailingTestsInReports = false#' build.gradle.kts + - scripts/wait_for_emulator.sh + - scripts/runAllScreenshotCombinations noCI false + - scripts/screenshotSummary.sh + - name: notify + image: drillster/drone-email + settings: port: 587 - username: $EMAIL_USERNAME - password: $EMAIL_PASSWORD from: nextcloud-drone@kaminsky.me recipients_only: true - recipients: [ $EMAIL_RECIPIENTS ] - secrets: [ EMAIL_USERNAME, EMAIL_PASSWORD, EMAIL_RECIPIENTS, EMAIL_HOST ] - when: - event: push - status: failure - branch: master - -matrix: - FLAVOUR: - - Generic - - Gplay - - Lint + username: + from_secret: EMAIL_USERNAME + password: + from_secret: EMAIL_PASSWORD + recipients: + from_secret: EMAIL_RECIPIENTS + host: + from_secret: EMAIL_HOST + when: + event: + - push + status: + - failure + branch: + - master + - stable-* +trigger: + event: + - cron + cron: + - allscreenshots +--- +kind: secret +name: GIT_TOKEN +data: XIoa9IYq+xQ+N5iln8dlpWv0jV6ROr7HuE24ioUr4uQ8m8SjyH0yognWYLYLqnbTKrFWlFZiEMQTH/sZiWjRFvV1iL0= +--- +kind: signature +hmac: de23b70b660e9f78e936d89699fd24777a83c8caaad97d086bbc0c8a0373aa91 -branches: master +... diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000000..67e5fa3c2eae --- /dev/null +++ b/.editorconfig @@ -0,0 +1,51 @@ +# .editorconfig + +# see http://EditorConfig.org + +# SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +# This is the file in the root of the project. +# For sub folders you can have other files that override only some settings. +# For these, this settings should be false. +root=true + +[*] +max_line_length=120 +# use spaces, not tabs. +indent_style=space +indent_size=4 + +[*.yml] +max_line_length=150 + +charset=utf-8 + +# Trimming is good for consistency +trim_trailing_whitespace=true +# I've seen cases where a missing new_line was ignored on *nix systems. +# Never again with this setting! +insert_final_newline=true + +[*.properties] +# Exception for Java properties files should be encoded latin1 (aka iso8859-1) +charset=latin1 + +[*.{cmd,bat}] +# batch files on Windows should stay with CRLF +end_of_line=crlf + +[*.md] +trim_trailing_whitespace=false + +[.drone.yml] +indent_size=2 + +[*.{kt,kts}] +ktlint_code_style = android_studio +# IDE does not follow this Ktlint rule strictly, but the default ordering is pretty good anyway, so let's ditch it +ktlint_standard_import-ordering = disabled +ktlint_standard_no-consecutive-comments = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable +ij_kotlin_allow_trailing_comma = false +ij_kotlin_allow_trailing_comma_on_call_site = false diff --git a/.github/.config.yml b/.github/.config.yml new file mode 100644 index 000000000000..001818407138 --- /dev/null +++ b/.github/.config.yml @@ -0,0 +1,3 @@ +firstPRMergeComment: > + Thanks for your first pull request and welcome to the community! + Feel free to keep them coming! If you are looking for issues to tackle then have a look at this selection: https://github.com/nextcloud/android/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 diff --git a/.github/.config.yml.license b/.github/.config.yml.license new file mode 100644 index 000000000000..f6133f557dce --- /dev/null +++ b/.github/.config.yml.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000000..c7799c74190c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +# You can add one username per supported platform and one custom link +custom: https://nextcloud.com/include/ diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000000..84acb9771770 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,132 @@ +name: "🐛 Bug report: Nextcloud Android Client" +description: "Submit a report and help us improve the Nextcloud Android Client" +labels: ["bug", "0. Needs triage"] +body: + - type: checkboxes + id: before-posting + attributes: + label: "⚠️ Before posting ⚠️" + description: All conditions are **required**. Your issue can be closed if these are checked incorrectly. + options: + - label: This is a **bug**, not a question or an enhancement. + required: true + - label: I've [searched for similar issues](https://github.com/nextcloud/android/issues) and didn't find a duplicate. + required: true + - label: I've written a clear and descriptive title for this issue, not just "Bug" or "Crash". + required: true + - label: I agree to follow Nextcloud's [Code of Conduct](https://nextcloud.com/contribute/code-of-conduct/). + required: true + - type: textarea + id: repro-steps + attributes: + label: Steps to reproduce + description: | + What are the steps to reproduce this issue? Please be as specific as possible. + If you can't reproduce it, please add an explanation. + placeholder: | + 1. + 2. + 3. + validations: + required: true + - type: textarea + id: expected-behaviour + attributes: + label: Expected behaviour + description: Tell us what should happen. + validations: + required: true + - type: textarea + id: actual-behaviour + attributes: + label: Actual behaviour + description: Tell us what happens instead, as detailed as possible. + validations: + required: true +# disabled because try.nextcloud.com is not working +# - type: dropdown +# id: repro-on-try +# attributes: +# label: Can you reproduce this problem on try.nextcloud.com? +# description: | +# 1. Create a demo account in [try.nextcloud.com](https://try.nextcloud.com). You'll be logged in automatically. +# 2. Got to Settings -> Security and create a new app password +# 3. Log in to this account with this app password by choosing "Alternative login with app token" during the login process. +# options: +# - "Yes" +# - "No" +# - Not applicable (explain in "additional information") +# validations: +# required: true + - type: markdown + attributes: + value: "## Environment information" + - type: input + id: android-version + attributes: + label: Android version + validations: + required: true + - type: input + id: device-model + attributes: + label: Device brand and model + validations: + required: true + - type: dropdown + id: stock-or-custom + attributes: + label: Stock or custom OS? + options: + - Stock + - Custom (explain in "additional information") + validations: + required: true + - type: input + id: app-version + attributes: + label: Nextcloud android app version + description: Check the _About_ section in the Settings screen + validations: + required: true + - type: input + id: server-version + attributes: + label: Nextcloud server version + description: Check _About_ in the top web menu (top right corner) + validations: + required: true + - type: dropdown + id: reverse-proxy + attributes: + label: Using a reverse proxy? + options: + - "I don't know" + - "Yes" + - "No" + validations: + required: true + - type: markdown + attributes: + value: "## Logs" + - type: textarea + id: android-logs + attributes: + label: Android logs + description: | + Please **drop a log file** here. + Log file can be obtained: + - At `storage/emulated/0/data/nextcloud.log` on beta or dev versions + - By using [`logcat`](https://github.com/nextcloud/android#getting-debug-info-via-logcat-mag) otherwise + If you are unable to post logs, explain why in "Additional information" + - type: textarea + id: server-logs + attributes: + label: Server error logs + description: Paste your server error logs here if available. Will be automatically formatted. + render: bash + - type: textarea + id: additional-info + attributes: + label: Additional information + description: Enter any additional information here diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml.license b/.github/ISSUE_TEMPLATE/bug_report.yml.license new file mode 100644 index 000000000000..8dae4293a3ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..f7550392d92b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +contact_links: + - name: 🚨 Report a security or privacy issue + url: https://hackerone.com/nextcloud + about: Report security and privacy related issues privately to the Nextcloud team, so we can coordinate the fix and release without potentially exposing all Nextcloud servers and users in the meantime. + - name: 🚨 报告安全或隐私问题 + url: https://hackerone.com/nextcloud + about: 请以私密方式向 Nextcloud 团队报告安全和隐私相关问题,以便我们能够协调修复工作并安排发布计划,避免在此期间可能暴露所有 Nextcloud 服务器和用户的风险。 + - name: ❓ Community Support and Help + url: https://help.nextcloud.com/ + about: Configuration, webserver/proxy or performance issues and other questions + - name: 💼 Nextcloud Enterprise + url: https://portal.nextcloud.com/ + about: If you are a Nextcloud Enterprise customer, or need Professional support, so it can be resolved directly by our dedicated engineers more quickly +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/config.yml.license b/.github/ISSUE_TEMPLATE/config.yml.license new file mode 100644 index 000000000000..8dae4293a3ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000000..468572c9b6a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,48 @@ +--- +name: 🚀 Feature request +about: Suggest an idea for this project +labels: enhancement, 0. Needs triage +--- + + + + + + +### How to use GitHub + +* Please use the 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to show that you are interested into the same feature. +* Please don't comment if you have no relevant information to add. It's just extra noise for everyone subscribed to this issue. +* Subscribe to receive notifications on status change and new comments. + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md.license b/.github/ISSUE_TEMPLATE/feature_request.md.license new file mode 100644 index 000000000000..8dae4293a3ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000000..4901055376bc --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ + +### 🖼️ Screenshots + +🏚️ Before | 🏡 After +---|--- +B | A + +### 🏁 Checklist + +- [ ] Tests written, or not not needed diff --git a/.github/pull_request_template.md.license b/.github/pull_request_template.md.license new file mode 100644 index 000000000000..44275b2b58eb --- /dev/null +++ b/.github/pull_request_template.md.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-FileCopyrightText: 2023 Marcel Hibbe +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only diff --git a/.github/workflows/QA_keystore.jks b/.github/workflows/QA_keystore.jks new file mode 100644 index 000000000000..2b8fb9bc2312 Binary files /dev/null and b/.github/workflows/QA_keystore.jks differ diff --git a/.github/workflows/QA_keystore.jks.license b/.github/workflows/QA_keystore.jks.license new file mode 100644 index 000000000000..f070b8a4c019 --- /dev/null +++ b/.github/workflows/QA_keystore.jks.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml new file mode 100644 index 000000000000..39aebc63a2e5 --- /dev/null +++ b/.github/workflows/analysis.yml @@ -0,0 +1,76 @@ +# synced from @nextcloud/android-config + +# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2025 Alper Ozturk +# SPDX-FileCopyrightText: 2023 Tobias Kaminsky +# SPDX-FileCopyrightText: 2023 Andy Scherzinger +# SPDX-FileCopyrightText: 2023 Josh Richards +# SPDX-FileCopyrightText: 2025 Marcel Hibbe +# SPDX-License-Identifier: GPL-3.0-or-later + +name: "Analysis" + +on: + pull_request: + branches: [ "master", "main", "stable-*" ] + push: + branches: [ "master", "main", "stable-*" ] + +permissions: + pull-requests: write + contents: write + +concurrency: + group: analysis-wrapper-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + analysis: + runs-on: ubuntu-latest + steps: + - name: Disabled on forks + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} + run: | + echo 'Can not analyze PRs from forks' + exit 1 + - name: Setup variables # zizmor: ignore[template-injection] + id: get-vars + run: | + if [ -z "$GITHUB_HEAD_REF" ]; then + # push + { + echo "branch=$GITHUB_REF_NAME" + echo "pr=$GITHUB_RUN_ID" + echo "repo=${{ github.repository }}" + } >> "$GITHUB_OUTPUT" + else + # pull request + { + echo "branch=$GITHUB_HEAD_REF" + echo "pr=${{ github.event.pull_request.number }}" + echo "repo=${{ github.event.pull_request.head.repo.full_name }}" + } >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: ${{ steps.get-vars.outputs.repo }} + ref: ${{ steps.get-vars.outputs.branch }} + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: "temurin" + java-version: 21 + - name: Install dependencies + run: | + sudo apt install python3-defusedxml + - name: Run analysis wrapper + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p "$HOME/.gradle" + { + echo "org.gradle.jvmargs=-Xmx1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8" + echo "org.gradle.configureondemand=true" + } > "$HOME/.gradle/gradle.properties" + scripts/analysis/analysis-wrapper.sh "${{ steps.get-vars.outputs.branch }}" "${{ secrets.LOG_USERNAME }}" "${{ secrets.LOG_PASSWORD }}" "$GITHUB_RUN_NUMBER" "${{ steps.get-vars.outputs.pr }}" diff --git a/.github/workflows/assembleFlavors.yml b/.github/workflows/assembleFlavors.yml new file mode 100644 index 000000000000..bf44361cc250 --- /dev/null +++ b/.github/workflows/assembleFlavors.yml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: "Assemble" + +on: + pull_request: + branches: [ master, stable-* ] + +# Declare default permissions as read only. +permissions: read-all + +concurrency: + group: assemble-flavors-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + flavor: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + flavor: [ Generic, Gplay, Huawei ] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: "temurin" + java-version: 21 + - uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - name: Build ${{ matrix.flavor }} + run: | + echo "org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" >> gradle.properties + echo "org.gradle.caching=true" >> gradle.properties + echo "org.gradle.parallel=true" >> gradle.properties + echo "org.gradle.configureondemand=true" >> gradle.properties + ./gradlew assemble${{ matrix.flavor }} diff --git a/.github/workflows/autoApproveSync.yml b/.github/workflows/autoApproveSync.yml new file mode 100644 index 000000000000..c215be0b3e89 --- /dev/null +++ b/.github/workflows/autoApproveSync.yml @@ -0,0 +1,40 @@ +# synced from @nextcloud/android-config + +# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2023 Álvaro Brey +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Auto approve sync +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] + branches: + - master + - main + types: + - opened + - reopened + - synchronize + - labeled + +concurrency: + group: sync-approve-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + pull-requests: write + +jobs: + auto-approve: + name: Auto approve sync + runs-on: ubuntu-latest + if: ${{ contains(github.event.pull_request.labels.*.name, 'sync') && github.actor == 'nextcloud-android-bot' }} + steps: + - name: Disabled on forks + if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + run: | + echo 'Can not approve PRs from forks' + exit 1 + + - uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 # v4.0.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 000000000000..a08122a435e0 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: Check + +on: + pull_request: + branches: [ master, stable-* ] + +# Declare default permissions as read only. +permissions: read-all + +concurrency: + group: check-kotlin-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + task: [ detekt, spotlessKotlinCheck, lint ] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: "temurin" + java-version: 21 + - name: Check ${{ matrix.task }} + run: ./gradlew ${{ matrix.task }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000000..a6ef19196628 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,60 @@ +# synced from @nextcloud/android-config + +# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2023-2024 Andy Scherzinger +# SPDX-FileCopyrightText: 2022 Tobias Kaminsky +# SPDX-FileCopyrightText: 2022 Álvaro Brey +# SPDX-FileCopyrightText: 2025 Marcel Hibbe +# SPDX-License-Identifier: GPL-3.0-or-later + +name: "CodeQL" + +on: + push: + branches: [ "master", "main", "stable-*" ] + pull_request: + branches: [ "master", "main" ] + schedule: + - cron: '24 18 * * 3' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [ 'java' ] + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set Swap Space + if: runner.environment == 'github-hosted' + uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0 + with: + swap-size-gb: 10 + - name: Initialize CodeQL + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + languages: ${{ matrix.language }} + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: "temurin" + java-version: 21 + - name: Assemble + run: | + mkdir -p "$HOME/.gradle" + echo "org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" > "$HOME/.gradle/gradle.properties" + ./gradlew --no-daemon assembleDebug + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 diff --git a/.github/workflows/detectNewJavaFiles.yml b/.github/workflows/detectNewJavaFiles.yml new file mode 100644 index 000000000000..908e3fd288f8 --- /dev/null +++ b/.github/workflows/detectNewJavaFiles.yml @@ -0,0 +1,43 @@ +# synced from @nextcloud/android-config + +# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2023 Andy Scherzinger +# SPDX-FileCopyrightText: 2022 Tobias Kaminsky +# SPDX-FileCopyrightText: 2022 Álvaro Brey +# SPDX-License-Identifier: GPL-3.0-or-later + +name: "Detect new java files" + +on: + pull_request: + branches: [ master, main, stable-* ] + +permissions: read-all + +concurrency: + group: detect-new-java-files-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + detectNewJavaFiles: + runs-on: ubuntu-latest + steps: + - id: file_changes + uses: trilom/file-changes-action@a6ca26c14274c33b15e6499323aac178af06ad4b # v1.2.4 + with: + output: ',' + - name: Detect new java files + run: | + if [ -z '${{ steps.file_changes.outputs.files_added }}' ]; then + echo "No new files added" + exit 0 + fi + new_java=$(echo '${{ steps.file_changes.outputs.files_added }}' | tr ',' '\n' | grep '\.java$' | cat) + if [ -n "$new_java" ]; then + # shellcheck disable=SC2016 + printf 'New java files detected:\n```\n%s\n```\n' "$new_java" | tee "$GITHUB_STEP_SUMMARY" + exit 1 + else + echo "No new java files detected" + exit 0 + fi diff --git a/.github/workflows/detectWrongSettings.yml b/.github/workflows/detectWrongSettings.yml new file mode 100644 index 000000000000..ebc50bc54bcc --- /dev/null +++ b/.github/workflows/detectWrongSettings.yml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2023 Tobias Kaminsky +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: "Detect wrong settings" + +on: + pull_request: + branches: [ master, stable-* ] + +# Declare default permissions as read only. +permissions: read-all + +concurrency: + group: detect-wrong-settings-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + detectWrongSettings: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: "temurin" + java-version: 21 + - name: Detect SNAPSHOT + run: scripts/analysis/detectWrongSettings.sh diff --git a/.github/workflows/lib.sh b/.github/workflows/lib.sh new file mode 100644 index 000000000000..3bb8b10f7930 --- /dev/null +++ b/.github/workflows/lib.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2022 Álvaro Brey +# SPDX-License-Identifier: AGPL-3.0-or-later +# + +## This file is intended to be sourced by other scripts + + +function err() { + echo >&2 "$@" +} + + +function curl_gh() { + if [[ -n "$GITHUB_TOKEN" ]] + then + curl \ + --silent \ + --header "Authorization: token $GITHUB_TOKEN" \ + "$@" + else + err "WARNING: No GITHUB_TOKEN found. Skipping API call" + fi + +} diff --git a/.github/workflows/lib.sh.license b/.github/workflows/lib.sh.license new file mode 100644 index 000000000000..23bad5cb8a9c --- /dev/null +++ b/.github/workflows/lib.sh.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2019-2025 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/.github/workflows/pr-feedback.yml b/.github/workflows/pr-feedback.yml new file mode 100644 index 000000000000..a3a2d1296c7a --- /dev/null +++ b/.github/workflows/pr-feedback.yml @@ -0,0 +1,55 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization + +# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2023 Marcel Klehr +# SPDX-FileCopyrightText: 2023 Joas Schilling <213943+nickvergessen@users.noreply.github.com> +# SPDX-FileCopyrightText: 2023 Daniel Kesselberg +# SPDX-FileCopyrightText: 2023 Florian Steffens +# SPDX-License-Identifier: MIT + +name: 'Ask for feedback on PRs' +on: + schedule: + - cron: '30 1 * * *' + +permissions: + contents: read + pull-requests: write + +jobs: + pr-feedback: + if: ${{ github.repository_owner == 'nextcloud' }} + runs-on: ubuntu-latest + steps: + - name: The get-github-handles-from-website action + uses: marcelklehr/get-github-handles-from-website-action@06b2239db0a48fe1484ba0bfd966a3ab81a08308 # v1.0.1 + id: scrape + with: + website: 'https://nextcloud.com/team/' + + - name: Get blocklist + id: blocklist + run: | + blocklist=$(curl https://raw.githubusercontent.com/nextcloud/.github/master/non-community-usernames.txt | paste -s -d, -) + echo "blocklist=$blocklist" >> "$GITHUB_OUTPUT" + + - uses: nextcloud/pr-feedback-action@5227c55be184087d0aef6338bee210d8620b6297 # main + with: + feedback-message: | + Hello there, + Thank you so much for taking the time and effort to create a pull request to our Nextcloud project. + + We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process. + + Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6 + + Thank you for contributing to Nextcloud and we hope to hear from you soon! + + (If you believe you should not receive this message, you can add yourself to the [blocklist](https://github.com/nextcloud/.github/blob/master/non-community-usernames.txt).) + days-before-feedback: 14 + start-date: '2024-04-30' + exempt-authors: '${{ steps.blocklist.outputs.blocklist }},${{ steps.scrape.outputs.users }}' + exempt-bots: true diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 000000000000..cca30141ac60 --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2023 Andy Scherzinger +# SPDX-License-Identifier: MIT +name: "QA" + +on: + pull_request: + branches: [ main, master, stable-* ] + +permissions: + pull-requests: write + contents: read + +concurrency: + group: qa-build-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + qa: + runs-on: ubuntu-latest + steps: + - name: Check if secrets are available + run: echo "ok=${{ secrets.KS_PASS != '' }}" >> "$GITHUB_OUTPUT" + id: check-secrets + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ steps.check-secrets.outputs.ok == 'true' }} + with: + persist-credentials: false + + - name: set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + if: ${{ steps.check-secrets.outputs.ok == 'true' }} + with: + distribution: "temurin" + java-version: 21 + + - name: Build QA + if: ${{ steps.check-secrets.outputs.ok == 'true' }} + env: + KS_PASS: ${{ secrets.KS_PASS }} + KEY_PASS: ${{ secrets.KEY_PASS }} + LOG_USERNAME: ${{ secrets.LOG_USERNAME }} + LOG_PASSWORD: ${{ secrets.LOG_PASSWORD }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p "$HOME/.gradle" + echo "org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" > "$HOME/.gradle/gradle.properties" + echo "org.gradle.caching=true; org.gradle.parallel=true; org.gradle.configureondemand=true" >> "$HOME/.gradle/gradle.properties" + [ -e app/build.gradle ] && sed -i "/qa/,/\}/ s/versionCode .*/versionCode ${{github.event.number}} /" "app/build.gradle" + [ -e app/build.gradle ] && sed -i "/qa/,/\}/ s/versionName .*/versionName \"${{github.event.number}}\"/" "app/build.gradle" + [ -e app/build.gradle.kts ] && sed -i "/qa/,/\}/ s/versionCode .*/versionCode = ${{github.event.number}} /" "app/build.gradle.kts" + [ -e app/build.gradle.kts ] && sed -i "/qa/,/\}/ s/versionName .*/versionName = \"${{github.event.number}}\"/" "app/build.gradle.kts" + ./gradlew assembleQaDebug + $(find /usr/local/lib/android/sdk/build-tools/*/apksigner | sort | tail -n1) sign --ks-pass pass:"$KS_PASS" --key-pass pass:"$KEY_PASS" --ks-key-alias key0 --ks ".github/workflows/QA_keystore.jks" app/build/outputs/apk/qa/debug/*qa-debug*.apk + + - name: Upload APK + id: upload-apk + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + with: + path: app/build/outputs/apk/qa/debug/*qa-debug*.apk + retention-days: 7 + archive: false + + - name: Create QR Code + run: | + sudo apt-get -y install qrencode + qrencode -o qr.png "${{ steps.upload-apk.outputs.artifact-url }}" + + - name: Upload QR + id: upload-qr + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + with: + path: qr.png + retention-days: 7 + archive: false + + - name: Comment PR + uses: thollander/actions-comment-pull-request@e4a76dd2b0a3c2027c3fd84147a67c22ee4c90fa + with: + message: | + APK file: ${{ steps.upload-apk.outputs.artifact-url }} + To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app. + ![qrcode](${{ steps.upload-qr.outputs.artifact-url }}) (please click on link to get QR code displayed) diff --git a/.github/workflows/renovate-approve-merge.yml b/.github/workflows/renovate-approve-merge.yml new file mode 100644 index 000000000000..b92491bf3215 --- /dev/null +++ b/.github/workflows/renovate-approve-merge.yml @@ -0,0 +1,61 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Auto approve renovate PRs + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] + branches: + - main + - master + - stable* + +permissions: + contents: read + +concurrency: + group: renovate-approve-merge-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + auto-approve-merge: + if: github.event.pull_request.user.login == 'renovate[bot]' + runs-on: ubuntu-latest + permissions: + # for hmarr/auto-approve-action to approve PRs + pull-requests: write + + steps: + - name: Disabled on forks + if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + run: | + echo 'Can not approve PRs from forks' + exit 1 + + - uses: mdecoleman/pr-branch-name@55795d86b4566d300d237883103f052125cc7508 # v3.0.0 + id: branchname + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # GitHub actions bot approve + - uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 # v4.0.0 + if: github.actor == 'renovate[bot]' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.head_ref }} + + # Enable GitHub auto merge + - name: Enable Pull Request Automerge + if: github.actor == 'renovate[bot]' + run: gh pr merge --merge --auto + env: + GH_TOKEN: ${{ secrets.AUTOMERGE }} + diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml new file mode 100644 index 000000000000..3f485f875f7e --- /dev/null +++ b/.github/workflows/reuse.yml @@ -0,0 +1,27 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization + +# SPDX-FileCopyrightText: 2022 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: CC0-1.0 + +name: REUSE Compliance Check + +on: [pull_request] + +permissions: + contents: read + +jobs: + reuse-compliance-check: + runs-on: ubuntu-latest-low + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: REUSE Compliance Check + uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000000..9a1c554023ab --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,47 @@ +# synced from @nextcloud/android-config + +# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2023 Andy Scherzinger +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Scorecard supply-chain security +on: + branch_protection_rule: + schedule: + - cron: '32 23 * * 4' + push: + branches: [ "main", "master" ] + +# Declare default permissions as read only. +permissions: read-all + +concurrency: + group: scorecard-supply-chain-security-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + + steps: + - name: "Checkout code" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: false + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/screenShotTest.yml b/.github/workflows/screenShotTest.yml new file mode 100644 index 000000000000..cf748ae4e842 --- /dev/null +++ b/.github/workflows/screenShotTest.yml @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: "Screenshot Test" + +on: + pull_request: + branches: [ master, stable-* ] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: screenshot-test-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + screenshot: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + scheme: [ Light ] + color: [ blue ] + api-level: [ 28 ] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Gradle cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + - name: AVD cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: "temurin" + java-version: 21 + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + arch: x86 + sdcard-path-or-size: 100M + target: google_apis + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -skin 500x833 + script: echo "Generated AVD snapshot for caching." + + - name: Configure gradle daemon + run: | + mkdir -p $HOME/.gradle + echo "org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" > $HOME/.gradle/gradle.properties + echo "org.gradle.caching=true" >> $HOME/.gradle/gradle.properties + echo "org.gradle.parallel=true" >> $HOME/.gradle/gradle.properties + echo "org.gradle.configureondemand=true" >> $HOME/.gradle/gradle.properties + + - name: Build generic flavor + run: ./gradlew assembleGenericDebug + + - name: Delete old comments + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: ${{ always() }} + run: scripts/deleteOldComments.sh "${{ matrix.color }}-${{ matrix.scheme }}" "Screenshot" ${{github.event.number}} + + - name: Run screenshot tests + env: + SHOT_TEST: "true" + uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + arch: x86 + sdcard-path-or-size: 100M + target: google_apis + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -skin 500x833 + script: ./gradlew uninstallAll genericDebugExecuteScreenshotTests -Dorg.gradle.jvmargs="--add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/java.nio.channels=ALL-UNNAMED --add-exports java.base/sun.nio.ch=ALL-UNNAMED" -Pandroid.testInstrumentationRunnerArguments.annotation=com.owncloud.android.utils.ScreenshotTest -Pandroid.testInstrumentationRunnerArguments.COLOR=${{ matrix.color }} -Pandroid.testInstrumentationRunnerArguments.DARKMODE=${{ matrix.scheme }} + - name: upload failing results + if: ${{ failure() }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: scripts/uploadReport.sh "${{ secrets.LOG_USERNAME }}" "${{ secrets.LOG_PASSWORD }}" ${{github.event.number}} "${{ matrix.color }}-${{ matrix.scheme }}" "Screenshot" ${{github.event.number}} + - name: Archive Espresso results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ always() }} + with: + name: Report-${{ matrix.color }}-${{ matrix.scheme }} + path: app/build/reports + retention-days: 4 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000000..478c094b0e30 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,35 @@ +# synced from @nextcloud/android-config + +# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2023 Tobias Kaminsky +# SPDX-FileCopyrightText: 2022 Álvaro Brey +# SPDX-License-Identifier: GPL-3.0-or-later + +name: 'Close stale issues' +on: + schedule: + - cron: '0 0 * * *' + +# Declare default permissions as read only. +permissions: read-all + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + with: + days-before-stale: 28 + days-before-close: 14 + days-before-pr-close: -1 + only-labels: 'bug,needs info' + exempt-issue-labels: 'no-stale' + stale-issue-message: >- + This bug report did not receive an update in the last 4 weeks. + Please take a look again and update the issue with new details, + otherwise the issue will be automatically closed in 2 weeks. Thank you! + exempt-all-pr-milestones: true + labels-to-remove-when-unstale: 'needs info' diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 000000000000..4b3dc2f50ca4 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2022-2025 Nextcloud GmbH and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: Unit tests + +on: + pull_request: + branches: [ master, stable-* ] + push: + branches: [ master, stable-* ] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: unit-tests-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: "temurin" + java-version: 21 + + - name: Delete old comments + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: ${{ always() }} + run: scripts/deleteOldComments.sh "test" "Unit" ${{github.event.number}} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + + - name: Run unit tests with coverage + run: ./gradlew jacocoTestGplayDebugUnitTest + + - name: Upload failing results + if: ${{ failure() }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: scripts/uploadReport.sh "${{ secrets.LOG_USERNAME }}" "${{ secrets.LOG_PASSWORD }}" ${{github.event.number}} "test" "Unit" ${{github.event.number}} + + - name: Upload coverage to codecov + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: unit + fail_ci_if_error: true + files: app/build/reports/jacoco/jacocoTestGplayDebugUnitTestReport/jacoco.xml + + - name: Upload jacoco artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-results + path: app/build/reports/jacoco/jacocoTestGplayDebugUnitTestReport/html/ diff --git a/.github/workflows/uploadArtifact.sh b/.github/workflows/uploadArtifact.sh new file mode 100755 index 000000000000..bf96572045ab --- /dev/null +++ b/.github/workflows/uploadArtifact.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# +# SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2019-2022 Tobias Kaminsky +# SPDX-License-Identifier: AGPL-3.0-or-later +# + +#1: LOG_USERNAME +#2: LOG_PASSWORD +#3: DRONE_BUILD_NUMBER +#4: DRONE_PULL_REQUEST + + +PUBLIC_URL=https://www.kaminsky.me/nc-dev/android-artifacts +USER=$1 +PASS=$2 +BUILD=$3 +PR=$4 +GITHUB_TOKEN=$5 +DAV_URL=https://nextcloud.kaminsky.me/remote.php/dav/files/$USER/android-artifacts/ + +source .github/workflows/lib.sh +REPO=$(cat scripts/repo) + +if ! test -e app/build/outputs/apk/qa/debug/*qa-debug*.apk ; then + exit 1 +fi +echo "Uploaded artifact to $DAV_URL/$BUILD.apk" + +# delete all old comments, starting with "APK file:" +oldComments=$(curl_gh -X GET https://api.github.com/repos/nextcloud/$REPO/issues/$PR/comments | jq '.[] | (.id |tostring) + "|" + (.user.login | test("github-actions") | tostring) + "|" + (.body | test("APK file:.*") | tostring)' | grep "true|true" | tr -d "\"" | cut -f1 -d"|") + +echo $oldComments | while read comment ; do + curl_gh -X DELETE https://api.github.com/repos/nextcloud/$REPO/issues/comments/$comment +done + +sudo apt-get -y install qrencode + +qrencode -o $PR.png "$PUBLIC_URL/$BUILD.apk" + +curl -u $USER:$PASS -X PUT $DAV_URL/$BUILD.apk --upload-file app/build/outputs/apk/qa/debug/*qa-debug*.apk +curl -u $USER:$PASS -X PUT $DAV_URL/$BUILD.png --upload-file $PR.png +curl_gh -X POST https://api.github.com/repos/nextcloud/$REPO/issues/$PR/comments -d "{ \"body\" : \"APK file: $PUBLIC_URL/$BUILD.apk

![qrcode]($PUBLIC_URL/$BUILD.png)

To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app. \" }" diff --git a/.gitignore b/.gitignore index 90c0f014a1b8..51120b52fc6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ +# SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only # built application files *.apk *.ap_ +*.aab # files for the dex VM *.dex @@ -19,17 +22,74 @@ target/ local.properties tests/local.properties +# Signing & secrets +*.jks +*.keystore +keystore.properties +release.properties +google-services.json +GoogleService-Info.plist +*.p12 +*.pem +secrets.properties +.env +.env.* + +# Windows +Thumbs.db +desktop.ini + # Mac .DS_Store files .DS_Store +# Linux/editor temp files +*~ +*.swp +*.swo +.vscode/ + # Proguard README proguard-project.txt tests/proguard-project.txt # Android Studio and Gradle specific entries .gradle -.idea -*.iml +.idea/* +!.idea/codeStyles/ build /gradle.properties +.attach_pid* +fastlane/Fastfile +*.hprof + +# NDK / C++ +*.so +obj/ +*.o +*.a + +# Testing & coverage +.kotlin/ +*.lcov +test-results/ +jacoco/ +*.exec + +# fastlane specific +**/fastlane/report.xml + +# deliver temporary files +**/fastlane/Preview.html + +# snapshot generated screenshots +**/fastlane/screenshots + +# scan temporary files +**/fastlane/test_output +/fastlane/vendor/ +/.bundle/ +/fastlane/.bundle +# python +**/__pycache__/ +/gradle/verification-keyring.gpg diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 000000000000..23a6687d1074 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,205 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000000..79ee123c2b23 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/Nextcloud___Android_Client.xml b/.idea/copyright/Nextcloud___Android_Client.xml new file mode 100644 index 000000000000..6d6e9dd68b2c --- /dev/null +++ b/.idea/copyright/Nextcloud___Android_Client.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 000000000000..bc106e52608a --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 000000000000..37273c65df5f --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/.idea/inspectionProfiles/ktlint.xml b/.idea/inspectionProfiles/ktlint.xml new file mode 100644 index 000000000000..631c09bc2163 --- /dev/null +++ b/.idea/inspectionProfiles/ktlint.xml @@ -0,0 +1,83 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000000..64580d143203 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + diff --git a/.pullapprove.yml b/.pullapprove.yml index 97232704e729..02b4872e2d0a 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -1,5 +1,6 @@ version: 2 - +# SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only # General settings to apply always_pending: title_regex: '(WIP|wip)' @@ -20,16 +21,21 @@ group_defaults: enabled: false reset_on_reopened: enabled: true + conditions: + labels: + exclude: + - dependencies groups: code-review: - required: 2 + required: 1 reject_value: -99 users: - AndyScherzinger - tobiasKaminsky - mario - - przybylski + - przybylski + - ardevd design-review: conditions: @@ -40,5 +46,5 @@ groups: required: 1 reject_value: -99 users: - - jancborchardt + - jancborchardt - eppfel diff --git a/.tx/config b/.tx/config index dd6ed4c9fb0c..f0d4139eff12 100644 --- a/.tx/config +++ b/.tx/config @@ -1,9 +1,10 @@ [main] host = https://www.transifex.com -[nextcloud.android] -file_filter = src/main/res/values-/strings.xml -source_file = src/main/res/values/strings.xml -type = ANDROID +[o:nextcloud:p:nextcloud:r:android] +file_filter = app/src/main/res/values-/strings.xml +source_file = app/src/main/res/values/strings.xml source_lang = en -lang_map = af_ZA: af-rZA, am_ET: am-rET, ar_AE: ar-rAE, ar_BH: ar-rBH, ar_DZ: ar-rDZ, ar_EG: ar-rEG, ar_IQ: ar-rIQ, ar_JO: ar-rJO, ar_KW: ar-rKW, ar_LB: ar-rLB, ar_LY: ar-rLY, ar_MA: ar-rMA, ar_OM: ar-rOM, ar_QA: ar-rQA, ar_SA: ar-rSA, ar_SY: ar-rSY, ar_TN: ar-rTN, ar_YE: ar-rYE, arn_CL: arn-rCL, as_IN: as-rIN, az_AZ: az-rAZ, ba_RU: ba-rRU, be_BY: be-rBY, bg_BG: bg-rBG, bn_BD: bn-rBD, bn_IN: bn-rIN, bo_CN: bo-rCN, br_FR: br-rFR, bs_BA: bs-rBA, ca_ES: ca-rES, co_FR: co-rFR, cs_CZ: cs-rCZ, cy_GB: cy-rGB, da_DK: da-rDK, de_AT: de-rAT, de_CH: de-rCH, de_DE: de-rDE, de_LI: de-rLI, de_LU: de-rLU, dsb_DE: dsb-rDE, dv_MV: dv-rMV, el_GR: el-rGR, en_AU: en-rAU, en_BZ: en-rBZ, en_CA: en-rCA, en_GB: b+en+001, en_IE: en-rIE, en_IN: en-rIN, en_JM: en-rJM, en_MY: en-rMY, en_NZ: en-rNZ, en_PH: en-rPH, en_SG: en-rSG, en_TT: en-rTT, en_US: en-rUS, en_ZA: en-rZA, en_ZW: en-rZW, en@pirate: en-rpirate, es_AR: es-rAR, es_BO: es-rBO, es_CL: es-rCL, es_CO: es-rCO, es_CR: es-rCR, es_DO: es-rDO, es_EC: es-rEC, es_ES: es-rES, es_GT: es-rGT, es_HN: es-rHN, es_MX: es-rMX, es_NI: es-rNI, es_PA: es-rPA, es_PE: es-rPE, es_PR: es-rPR, es_PY: es-rPY, es_SV: es-rSV, es_419: b+es+419, es_UY: es-rUY, es_VE: es-rVE, et_EE: et-rEE, eu_ES: eu-rES, fa_IR: fa-rIR, fi_FI: fi-rFI, fil_PH: fil-rPH, fo_FO: fo-rFO, fr_BE: fr-rBE, fr_CA: fr-rCA, fr_CH: fr-rCH, fr_FR: fr-rFR, fr_LU: fr-rLU, fr_MC: fr-rMC, fy_NL: fy-rNL, ga_IE: ga-rIE, gd_GB: gd-rGB, gl_ES: gl-rES, gsw_FR: gsw-rFR, gu_IN: gu-rIN, ha_NG: ha-rNG, he_IL: he-rIL, hi_IN: hi-rIN, hr_BA: hr-rBA, hr_HR: hr-rHR, hsb_DE: hsb-rDE, hu_HU: hu-rHU, hy_AM: hy-rAM, id_ID: id-rID, ig_NG: ig-rNG, ii_CN: ii-rCN, is_IS: is-rIS, it_CH: it-rCH, it_IT: it-rIT, iu_CA: iu-rCA, ja_JP: ja-rJP, ka_GE: ka-rGE, kk_KZ: kk-rKZ, kl_GL: kl-rGL, km_KH: km-rKH, kn_IN: kn-rIN, ko_KR: ko-rKR, kok_IN: kok-rIN, ku_IQ: ku-rIQ, ky_KG: ky-rKG, lb_LU: lb-rLU, lo_LA: lo-rLA, lt_LT: lt-rLT, lv_LV: lv-rLV, mi_NZ: mi-rNZ, mk_MK: mk-rMK, ml_IN: ml-rIN, mn_CN: mn-rCN, mn_MN: mn-rMN, moh_CA: moh-rCA, mr_IN: mr-rIN, ms_BN: ms-rBN, ms_MY: ms-rMY, my_MM: my, mt_MT: mt-rMT, nb_NO: nb-rNO, ne_NP: ne-rNP, nl_BE: nl-rBE, nl_NL: nl-rNL, nn_NO: nn-rNO, nso_ZA: nso-rZA, oc_FR: oc-rFR, or_IN: or-rIN, pa_IN: pa-rIN, pl_PL: pl-rPL, prs_AF: prs-rAF, ps_AF: ps-rAF, pt_BR: pt-rBR, pt_PT: pt-rPT, qut_GT: qut-rGT, quz_BO: quz-rBO, quz_EC: quz-rEC, quz_PE: quz-rPE, rm_CH: rm-rCH, ro_RO: ro-rRO, ru_RU: ru-rRU, rw_RW: rw-rRW, sa_IN: sa-rIN, sah_RU: sah-rRU, se_FI: se-rFI, se_NO: se-rNO, se_SE: se-rSE, si_LK: si-rLK, sk_SK: sk-rSK, sl_SI: sl-rSI, sma_NO: sma-rNO, sma_SE: sma-rSE, smj_NO: smj-rNO, smj_SE: smj-rSE, smn_FI: smn-rFI, sms_FI: sms-rFI, sq_AL: sq-rAL, sr_BA: sr-rBA, sr_CS: sr-rCS, sr_ME: sr-rME, sr_RS: sr-rRS, sr@latin: sr-rSP, sv_FI: sv-rFI, sv_SE: sv-rSE, sw_KE: sw-rKE, syr_SY: syr-rSY, ta_IN: ta-rIN, ta_LK: ta-rLK, te_IN: te-rIN, tg_TJ: tg-rTJ, th_TH: th-rTH, tk_TM: tk-rTM, tn_ZA: tn-rZA, tr_TR: tr-rTR, tt_RU: tt-rRU, tzm_DZ: tzm-rDZ, ug_CN: ug-rCN, uk_UA: uk-rUA, ur_PK: ur-rPK, uz_UZ: uz-rUZ, vi_VN: vi-rVN, wo_SN: wo-rSN, xh_ZA: xh-rZA, yo_NG: yo-rNG, zh_CN: zh-rCN, zh_CN.GB2312:zh-rBG, zh_HK: zh-rHK, zh_MO: zh-rMO, zh_SG: zh-rSG, zh_TW: zh-rTW, zu_ZA: zu-rZA +type = ANDROID +lang_map = en_SG: en-rSG, es_PA: es-rPA, mr_IN: mr-rIN, cs_CZ: cs-rCZ, vi_VN: vi-rVN, fr_MC: fr-rMC, it_CH: it-rCH, ta_LK: ta-rLK, kk_KZ: kk-rKZ, mn_CN: mn-rCN, mt_MT: mt-rMT, sma_SE: sma-rSE, si_LK: si-rLK, pl_PL: pl-rPL, de_AT: de-rAT, ii_CN: ii-rCN, hi_IN: hi-rIN, ar_BH: ar-rBH, ar_JO: ar-rJO, es_NI: es-rNI, quz_BO: quz-rBO, sr_CS: sr-rCS, es_CO: es-rCO, es_GT: es-rGT, ml_IN: ml-rIN, rm_CH: rm-rCH, zh_CN.GB2312: zh-rBG, hr_BA: hr-rBA, se_FI: se-rFI, tn_ZA: tn-rZA, tzm_DZ: tzm-rDZ, en_ZA: en-rZA, es_419: b+es+419, en_IN: en-rIN, my_MM: my, se_NO: se-rNO, am_ET: am-rET, arn_CL: arn-rCL, en_MY: en-rMY, es_HN: es-rHN, es_UY: es-rUY, en_AU: en-rAU, id: in, ku_IQ: ku-rIQ, pt_BR: pt-rBR, xh_ZA: xh-rZA, co_FR: co-rFR, en_BZ: en-rBZ, ha_NG: ha-rNG, or_IN: or-rIN, dsb_DE: dsb-rDE, fo_FO: fo-rFO, fr_CA: fr-rCA, ky_KG: ky-rKG, ar_LB: ar-rLB, es_AR: es-rAR, is_IS: is-rIS, ar_KW: ar-rKW, en_GB: b+en+001, fy_NL: fy-rNL, ar_QA: ar-rQA, hy_AM: hy-rAM, mn_MN: mn-rMN, nl_BE: nl-rBE, ar_OM: ar-rOM, as_IN: as-rIN, cy_GB: cy-rGB, he: iw, it_IT: it-rIT, nso_ZA: nso-rZA, ba_RU: ba-rRU, wo_SN: wo-rSN, lb_LU: lb-rLU, quz_EC: quz-rEC, uz_UZ: uz-rUZ, zh_TW: zh-rTW, ar_MA: ar-rMA, es_CL: es-rCL, es_VE: es-rVE, da_DK: da-rDK, et_EE: et-rEE, af_ZA: af-rZA, en@pirate: en-rpirate, ga_IE: ga-rIE, kok_IN: kok-rIN, ur_PK: ur-rPK, tg_TJ: tg-rTJ, ne_NP: ne-rNP, es_CR: es-rCR, fil_PH: fil-rPH, fr_CH: fr-rCH, gl_ES: gl-rES, se_SE: se-rSE, sr_BA: sr-rBA, es_DO: es-rDO, ms_MY: ms-rMY, oc_FR: oc-rFR, syr_SY: syr-rSY, ug_CN: ug-rCN, en_CA: en-rCA, en_JM: en-rJM, ko_KR: ko-rKR, be_BY: be-rBY, zh_HK: zh-rHK, nb_NO: nb-rNO, fi_FI: fi-rFI, fr_FR: fr-rFR, ar_SA: ar-rSA, az_AZ: az-rAZ, he_IL: he-rIL, zh_CN: zh-rCN, bn_BD: bn-rBD, el_GR: el-rGR, en_PH: en-rPH, sr@latin: sr-rSP, br_FR: br-rFR, ta_IN: ta-rIN, hu_HU: hu-rHU, lt_LT: lt-rLT, ar_AE: ar-rAE, en_ZW: en-rZW, ar_TN: ar-rTN, ka_GE: ka-rGE, en_TT: en-rTT, mi_NZ: mi-rNZ, zu_ZA: zu-rZA, fa_IR: fa-rIR, fr_LU: fr-rLU, lo_LA: lo-rLA, ms_BN: ms-rBN, rw_RW: rw-rRW, sl_SI: sl-rSI, tt_RU: tt-rRU, de_LI: de-rLI, es_EC: es-rEC, ps_AF: ps-rAF, id_ID: id-rID, smn_FI: smn-rFI, bg_BG: bg-rBG, lv_LV: lv-rLV, te_IN: te-rIN, iu_CA: iu-rCA, sms_FI: sms-rFI, es_PE: es-rPE, gd_GB: gd-rGB, hr_HR: hr-rHR, moh_CA: moh-rCA, smj_SE: smj-rSE, ar_LY: ar-rLY, de_LU: de-rLU, es_BO: es-rBO, sq_AL: sq-rAL, ar_SY: ar-rSY, tr_TR: tr-rTR, sr_RS: sr-rRS, sv_SE: sv-rSE, kl_GL: kl-rGL, quz_PE: quz-rPE, de_DE: de-rDE, sv_FI: sv-rFI, tk_TM: tk-rTM, bo_CN: bo-rCN, gsw_FR: gsw-rFR, pt_PT: pt-rPT, dv_MV: dv-rMV, uk_UA: uk-rUA, ar_YE: ar-rYE, zh_SG: zh-rSG, sw_KE: sw-rKE, en_IE: en-rIE, en_US: en-rUS, es_SV: es-rSV, qut_GT: qut-rGT, th_TH: th-rTH, ar_DZ: ar-rDZ, gu_IN: gu-rIN, kn_IN: kn-rIN, mk_MK: mk-rMK, es_MX: es-rMX, ig_NG: ig-rNG, smj_NO: smj-rNO, bn_IN: bn-rIN, de_CH: de-rCH, sk_SK: sk-rSK, es_PR: es-rPR, yo_NG: yo-rNG, sma_NO: sma-rNO, sa_IN: sa-rIN, en_NZ: en-rNZ, ja_JP: ja-rJP, pa_IN: pa-rIN, es_PY: es-rPY, nn_NO: nn-rNO, ar_EG: ar-rEG, bs_BA: bs-rBA, eu_ES: eu-rES, fr_BE: fr-rBE, km_KH: km-rKH, ru_RU: ru-rRU, sah_RU: sah-rRU, ca_ES: ca-rES, sr_ME: sr-rME, ro_RO: ro-rRO, prs_AF: prs-rAF, zh_MO: zh-rMO, es_ES: es-rES, hsb_DE: hsb-rDE, nl_NL: nl-rNL, ar_IQ: ar-rIQ + diff --git a/.tx/config.license b/.tx/config.license new file mode 100644 index 000000000000..39d0c459b123 --- /dev/null +++ b/.tx/config.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors +SPDX-FileCopyrightText: 2012 Bartosz Przybylski +SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..a825310a978c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ + +# Agents.md + +This file provides guidance to all AI agents (Claude, Codex, Gemini, etc.) working with code in this repository. + +You are an experienced engineer specialized on Java, Kotlin and familiar with the platform-specific details of Android. + +## Your Role + +- You implement features and fix bugs. +- Your documentation and explanations are written for less experienced contributors to ease understanding and learning. +- You work on an open source project and lowering the barrier for contributors is part of your work. + +## Project Overview + +The Nextcloud Android Client is a application to synchronize files from Nextcloud Server with your Android device. +Java, Kotlin, XML, Jetpack Compose are the key technologies used for building the app. + +## Project Structure: AI Agent Handling Guidelines + +'./app/src/main/java/com' main package of the project. +'.app/src/main/java/com/nextcloud/utils/extensions' package used for creating extensions. +'./app/src/main/res/values' used for translations. Only update +'./app/src/main/res/values/strings.xml'. Do not modify any other translation files or folders. Ignore all values- +directories (e.g., values-es, values-fr). + +## General Guidance + +Every new file needs to get a SPDX header in the first rows according to this template. +The year in the first line must be replaced with the year when the file is created (for example, 2026 for files first added in 2026). +The commenting signs need to be used depending on the file type. + +```plaintext +SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later +``` +Kotlin/Java: +```kotlin +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +``` + +XML: +```xml + +``` + +Avoid creating source files that implement multiple types; instead, place each type in its own dedicated source file. + +## Design + +- Follow Material Design 3 guidelines +- In addition to any Material Design wording guidelines, follow the Nextcloud wording guidelines at https://docs.nextcloud.com/server/latest/developer_manual/design/foundations.html#wording +- Ensure the app works in both light and dark theme +- Ensure the app works with different server primary colors by using the colorTheme of viewThemeUtils + +## Commit and Pull Request Guidelines + +### Commits + +- All commits must be signed off (`git commit -s`) per the Developer Certificate of Origin (DCO). All PRs target `master`. Backports use `/backport to stable-X.Y` in a PR comment. + +- Commit messages must follow the [Conventional Commits v1.0.0 specification](https://www.conventionalcommits.org/en/v1.0.0/#specification) — e.g. `feat(chat): add voice message playback`, `fix(call): handle MCU disconnect gracefully`. + +- Every commit made with AI assistance must include an `AI-assistant` trailer identifying the coding agent, its version, and the model(s) used: + + ``` + AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.6) + AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) + ``` + + General pattern: `AI-assistant: ( )` + + If multiple models are used for different roles, extend the trailer with named roles: + + ``` + AI-assistant: OpenCode v1.0.203 (plan: Claude Opus 4.5, edit: Claude Sonnet 4.5) + ``` + + Pattern with roles: `AI-assistant: (: , : )` + +### Pull Requests + +- Include a short summary of what changed. *Example:* `fix: prevent crash on empty todo title`. +- **Pull Request**: When the agent creates a PR, it should include a description summarizing the changes and why they were made. If a GitHub issue exists, reference it (e.g., “Closes #123”). + +## Code Style + +- Do not exceed 300 line of code per file. +- Line length: **120 characters** +- Standard Android Studio formatter with EditorConfig. +- Kotlin preferred for new code; legacy Java still present. +- Do not use decorative section-divider comments of any kind (e.g. `// ── Title ───`, `// ------`, `// ======`). +- Every new file must end with exactly one empty trailing line (no more, no less). +- Do not add comments, documentation for every function you created instead make it self explanatory as much as possible. +- Create models, states in different files instead of doing it one single file. +- Do not use magic number. +- Apply fail fast principle instead of using nested if-else statements. +- Do not use multiple boolean flags to determine states instead use enums or sealed classes. +- Use modern Java for Java classes. Optionals, virtual threads, records, streams if necessary. +- Avoid hardcoded strings, colors, dimensions. Use resources. +- Run lint, spotbugsGplayDebug, detekt, spotlessKotlinCheck and fix findings inside the files that have been changed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 04bb3bf8b039..3374f922b72c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,796 @@ -## 3.1.0 +## 3.33.0 (September 10, 2025) + +- Migrate to Glide 4 +- Performance improvements +- Fix gallery image scaling +- Bugfixes + +Minimum: NC 18 Server, Android 8.1 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/112 + +## 3.32.3 (August 21, 2025) + +- Bugfixes + +Minimum: NC 18 Server, Android 8.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/114 + +## 3.32.2 (July 18, 2025) + +- Resolved image blurriness issue. +- Fixed crash occurring in the conflict resolution dialog. +- Addressed crash in the upload finish receiver event handler. + +Minimum: NC 18 Server, Android 8.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/115 + +## 3.32.1 (July 14, 2025) + +- Bug fixes. + +Minimum: NC 18 Server, Android 8.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/113 + +## 3.32.0 (July 2, 2025) + +- Minimum supported Android version is 8.0. +- Scrolling performance has been increased in the media tab. +- Multi-select feature added to the media tab. +- Custom share permissions have been added. +- Bug fixes. + +Minimum: NC 18 Server, Android 8.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/107 + +## 3.31.4 (June 3, 2025) + +- Add missing auto migration + +Minimum: NC 18 Server, Android 7.1 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/110 + +## 3.31.3 (May 28, 2025) + +- fix simple sign up +- bugfixes +- update translations + +Minimum: NC 18 Server, Android 7.1 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/110 + +## 3.31.2 (May 20, 2025) + +- bring back MANAGE_EXTERNAL_STORAGE permission + +Minimum: NC 18 Server, Android 7.1 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/108 + +## 3.31.1 (April 3, 2025) + +- Various bug fixes and performance enhancements + +Minimum: NC 18 Server, Android 7.1 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/108 + +## 3.31.0 (February 25, 2025) + +- New share layout +- Various bug fixes and performance enhancements + +Minimum: NC 18 Server, Android 7.1 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/100 + +## 3.30.7 (January 6, 2025) + +- Fix crash of auto upload settings + +Minimum: NC 16 Server, Android 7.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/104 + +## 3.30.3 (October 22, 2024) + +- Bugfix for two way sync: sync only on wifi + +## 3.30.2 (October 21, 2024) + +- Bugfix for two way sync. Please check listed folders in settings -> internal two way sync + +Minimum: NC 16 Server, Android 7.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/99 + +## 3.30.1 (October 11, 2024) + +- Bugfixes + +Minimum: NC 16 Server, Android 7.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/99 + +## 3.29.1 (June 27, 2024) + +- Bugfixes + +Minimum: NC 16 Server, Android 7.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/93 + + + +## 3.29.0 (April 24, 2024) + +- NC Assistant +- Client certificates +- Personal files view +- REUSE compliance +- Bugfixes + +Minimum: NC 16 Server, Android 7.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/89 + +## 3.28.2 (April 4th, 2024) + +- Bugfixes + +Minimum: NC 16 Server, Android 7.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/90 + +## 3.28.1 (March 25th, 2024) + +- Bugfixes + +Minimum: NC 16 Server, Android 7.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/90 + +## 3.28.0 (February 13th, 2024) + +- E2E sharing +- Bugfixes + +Minimum: NC 16 Server, Android 7.0 Nougat + +For a full list, please see https://github.com/nextcloud/android/milestone/88 + +## 3.26.0 (September 16, 2023) + +- image editing +- image details, with map +- show other Nextcloud apps + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/84 + +## 3.25.0 (June 13, 2023) + +- show Groupfolder +- Tag in file listing + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/81 + +## 3.24.1 (February 21, 2023) + +- Fix crash in previous version when connecting to old server versions + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/80 + +## 3.24.0 (February 13, 2023) + +- Several performance optimizations by @starypatyk +- Support multi-page document scanning and exporting to PDF +- Many small bugfixes and improvements + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/78 + +## 3.23.1 (December 21, 2022) + +- Bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/79 + +## 3.23.0 (December 1, 2022) + +- File actions menu redesign +- Allow adding shortcuts to Home screen (@newhinton) +- Many bugfixes and performance optimizations + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/74 + +## 3.22.3 (November 3, 2022) + +- Bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/76 + +## 3.22.2 (October 24, 2022) + +- Bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/75 + +## 3.22.1 (October 20, 2022) + +- Bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/73 + +## 3.22.0 (October 19, 2022) + +- Material 3 UI revamp +- Dashboard widgets for Android launcher +- New tiled design for media view +- Many bugfixes and improvements + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/70 + +## 3.21.2 (September 1, 2022) + +- Bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +## 3.21.1 (August 26, 2022) + +- Bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/72 + +## 3.21.0 (August 8, 2022) + +- Fast scroll for all lists +- Media view + - Group photos as timeline by month + - Filter options +- File locking +- File export to sdcard +- Many bugfixes and improvements + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/66 + +## 3.20.3 (June 13th, 2022) + +- Minor bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/69 + +## 3.20.2 (June 9th, 2022) + +- Several minor bugfixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/68 + +## 3.20.1 (May 6th, 2022) + +- Several minor bugfixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/67 +## 3.20.0 (May 3rd, 2022) + +- Built-in PDF viewer +- Built-in document scanner +- Better choices for storage permissions +- File locking support +- Better UI for media gallery +- Many bugfixes and improvements + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/64 +## 3.19.1 (March 10, 2022) + +- Minor fixes and improvements + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/65 +## 3.19.0 (February 3, 2022) + +- Support external storage in Media page +- Connection fallback to IPv4 when IPv6 fails +- Many other fixes and improvements + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/62 +## 3.18.1 (December 22, 2021) + +- Fix connection bug for usernames with spaces +- Fix some crashes in search and share views +- Fix login for server URLs in IP:port format +- Lots of minor bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/63 + +## 3.18.0 (November 18, 2021) + +- Calendar backup/restore +- Unified search +- Sharing permissions improvements +- Minor bug fixes + +Minimum: NC 16 Server, Android 6.0 Marshmallow + +For a full list, please see https://github.com/nextcloud/android/milestone/61 + +## 3.17.1 (October, 20, 2021) + +- fix FIDO crash +- fix crash in documents provider while offline +- updated translations + +Minimum: NC 16 Server, Android 5.1 Lollipop + + +For a full list, please see https://github.com/nextcloud/android/milestone/60 + +## 3.17.0 (August, 19, 2021) + +- UI improvements (Avatar, password dialog) +- New video player for better streaming +- Many bug fixes +- Drop Android 5.0, new min version Android 5.1 + +Minimum: NC 16 Server, Android 5.1 Lollipop + + +For a full list, please see https://github.com/nextcloud/android/milestone/59 + +## 3.16.1 (June, 01, 2021) + +- Fix media tab not showing images/videos +- Connectivity checks fixed +- Crashing while retrieving avatar + +Minimum: NC 16 Server, Android 5.0 Lollipop + + +For a full list, please see https://github.com/nextcloud/android/milestone/58 + +## 3.16.0 (May, 05, 2021) + +- Enhance sharing +- Update template section when creating new files +- Pin protection update +- Updated notification handling during updates @newhinton +- UI improvements + +Minimum: NC 16 Server, Android 5.0 Lollipop + + +For a full list, please see https://github.com/nextcloud/android/milestone/55 + +## 3.15.1 (March, 10, 2021) + +- share fix +- passcode fix +- enhance share access + +Minimum: NC 16 Server, Android 5.0 Lollipop + + +For a full list, please see https://github.com/nextcloud/android/milestone/57 + +## 3.15.0 (February, 02, 2021) + +- Media instead of Photos: also show videos +- UI Improvement (shimmer) +- Bug fixes all over the place +- Drop Android 4.4, new min version Android 5.0 + +Minimum: NC 16 Server, Android 5.0 Lollipop + + +For a full list, please see https://github.com/nextcloud/android/milestone/52 + +## 3.14.3 (January, 13, 2021) + +- Fix crash when clicking "+" button +- Fix push notifications on some devices +- Fix updating of sharee list +- Fix crash during setting status +- Fix Crash Sharing files to Nextcloud via Android Apps + +For a full list, please see https://github.com/nextcloud/android/milestone/56 + +## 3.14.2 (January, 13, 2021) + +- Fix push notifications on some devices +- Fix updating of sharee list +- Fix crash during setting status +- Fix Crash Sharing files to Nextcloud via Android Apps + +For a full list, please see https://github.com/nextcloud/android/milestone/54 + +## 3.14.1 (December, 02, 2020) + +- Fix crash due to service not started in time +- Fix UI while media playback +- Fix uploading direct camera images with more than one picture +- Fix conflict handling on auto upload + +For a full list, please see https://github.com/nextcloud/android/milestone/53 + +## 3.14.0 (November, 18, 2020) + +- Prevent Firebase crashes: Exodus will warn about tracker, but code wise it is disabled +- Status support +- Document storage enhancement @tgrote +- Auto upload media detection improvements @AndyScherzinger +- Sharing UI rewrite +- Drop Android 4.3, new min version Android 4.4 + +For a full list, please see https://github.com/nextcloud/android/milestone/50 + +## 3.13.1 (September, 15, 2020) + +- bugfix release +- auto upload obey metered network +- fix adding account via qrCode +- fix deleting password on share +- fix conflict handling on auto upload +- lots more + +For a full list, please see https://github.com/nextcloud/android/milestone/51 + +## 3.13.0 (August, 18, 2020) + +- new UI overhaul @Shagequi @JorisBodin +- E2EE beta support +- dark mode enhancement @AndyScherzinger +- warn on outdated NC16 server +- requires Android 4.3 or newer + +For a full list, please see https://github.com/nextcloud/android/milestone/48 + +## 3.12.1 (July, 07, 2020) + +- UI does not hang when changing auto upload +- fix crash on contacts backup settings +- bugfixes + +For a full list, please see https://github.com/nextcloud/android/milestone/49 + +## 3.12.0 (June, 10, 2020) + +- add circle support for searching/displaying +- if no offline editor is available, use OO/Cool/Text +- add possibility to set expiration date on user/group shares (NC18+) +- rich workspaces can be disabled on server side +- improved loading view +- requires Android 4.2 or newer + +For a full list, please see https://github.com/nextcloud/android/milestone/42 + +## 3.11.1 (April, 23, 2020) + +- Crash while browsing files +- auto upload: + - fix wrong conflict detection on custom folder + - allow to choose default conflict strategy @fodinabor + - fix hanging UI after saving +- open office files online if no local app installed + +For a full list, please see https://github.com/nextcloud/android/milestone/47 + +## 3.11.0 (March, 26, 2020) + +- not enough space dialog @Shagequi +- fix shared search +- upload existing images in auto upload @koying @ArisuOngaku +- allow deep links @Charon77 +- support for circle +- last version supporting Android 4.1 + +For a full list, please see https://github.com/nextcloud/android/milestone/41 + +## 3.10.1 (February, 05, 2020) + +- fix crash on self-signed certificates +- fix openOffice open files with special chars + +For a full list, please see https://github.com/nextcloud/android/milestone/45 + +## 3.10.0 (January, 17, 2020) + +- Dark theme (@dan0xii, @AndyScherzinger) +- Rich workspace (NC18+) +- collaborative text editor (NC18+) +- links in Markdown previews clickable (@AndyScherzinger) +- Show/Hide auto upload list items (@AndyScherzinger) +- drop 4.0.x support +- outdated server warning set to NC15 +- latest supported version NC13 + +For a full list, please see https://github.com/nextcloud/android/milestone/40 + +## 3.9.2 (December, 05, 2019) + +- HOTFIX: fix login loop +- Fix crash on opening png images +- Translation updates + +For a full list, please see https://github.com/nextcloud/android/milestone/44 + +## 3.9.1 (December, 04, 2019) + +- Fix crash on opening png images +- Translation updates + +For a full list, please see https://github.com/nextcloud/android/milestone/43 + +## 3.9.0 (November, 12, 2019) + +- preview Markdown with syntax highlighting @AndyScherzinger +- improved DavX5 integration @bitfireAT +- AutoUpload: allow files to upload into subfolder +- new media player service @ezaquarii +- Remote wipe integration +- Print from within Collabora +- enhanced SingleSignOn +- outdated server warning set to NC14 + +For a full list, please see https://github.com/nextcloud/android/milestone/38 + +## 3.8.1 (October, 11, 2019) + +- upload images into subfolder, if source folder also has subfolder +- Fix registration of second account on first run +- fix disappearing account list +- fix recurring synced folder notification +- fix vanishing images +- auto upload: fix relative paths +- bugfix release +- updated translations + +For a full list, please see https://github.com/nextcloud/android/milestone/39 + +## 3.8.0 (September, 14, 2019) + +- FIDO U2F support on login +- load only 60 images on photo view, then load more on demand +- do not auto upload .thumbnail files +- allow to send crash report via email +- paste clipboard into Collabora +- use Conscrypt to support TLS1.3 +- show sharees in list view +- remote wipe +- same mimetype as server +- fix reloading in activity stream +- lots of bugfixes and refinements + +For a full list, please see https://github.com/nextcloud/android/milestone/35 + +## 3.7.2 (August, 16, 2019) + +- Transifex update +- bump to lib 1.5.0 + +For a full list, please see https://github.com/nextcloud/android/milestone/37 + +## 3.7.1 (July, 30, 2019) + +- fix for Global Scale + +For a full list, please see https://github.com/nextcloud/android/milestone/36 + +## 3.7.0 (July, 09, 2019) + +- Collabora enhancements +- Chromebook support +- delete push notifications if read on other device (NC 18 and newer) +- open file from notification +- open file from Talk app +- minimum supported server: NC12 +- end of life warning: NC13 and older +- lots of bugfixes and refinements under the hood to provide an even more stable app + +For a full list, please see https://github.com/nextcloud/android/milestone/32 + +## 3.6.2 (May, 23, 2019) +- fix bug when creating preview +- fix crash on opening app +- fix account switch +- fix jumping to top on sync + +For a full list, please see https://github.com/nextcloud/android/milestone/34 + +## 3.6.1 (May, 12, 2019) +- show reshares correctly +- allow open files from Talk +- collabora: hide loading delay warning if document is loaded +- correctly show idn string in drawer +- show outdated warning on NC13 +- enhance pass protection system +- bugfixes + +For a full list, please see https://github.com/nextcloud/android/milestone/33 + +## 3.6.0 (April, 09, 2019) +- remove "expert mode" +- show warning if server is unavailable +- delete notification on server +- actions in notifications +- add storage path chooser for local file picker +- show shared user +- show notes on sharing +- min supported server is NC12 +- warn on outdated server: <=NC14 + +For a full list, please see https://github.com/nextcloud/android/milestone/30 + +## 3.5.1 (March, 18, 2019) +- fixed SSO dialog +- abort sync on no connection +- fix chunked upload +- fix federated share +- fix button disabled state in folder sync preferences +- add storage picker to upload local chooser +- updated translations + +For a full list, please see https://github.com/nextcloud/android/milestone/31 + +## 3.5.0 (February, 13, 2019) +- Chunked upload: 1MB on mobile data, 10MB on Wi-Fi +- Switch to Material Design +- Option to not show notifications for new media folders +- Add support for QR codes & deep links +- Direct camera upload +- Fully working Document provider +- Detail view: Show complete date upon click +- Show correct share error message +- Use default/device font +- Sync all downloaded +- Add battery optimization warning + +For a full list, please see https://github.com/nextcloud/android/milestone/28 + +## 3.4.2 (January, 21, 2019) +- fix sharing to group +- show correct share error messages +- fix bug when searching for user/group if Talk is disabled + +For a full list, please see https://github.com/nextcloud/android/milestone/29 + +## 3.4.1 (December, 23, 2018) +- fix wrong detection of direct editing capability for RichDocuments + +## 3.4.0 (December, 17, 2018) +- hide download when creating share links +- direct editing files with Collabora (Collabora Server >=4.0) +- sort deleted files by deletion date by default +- set/edit notes on shares +- search inside of text files +- actions on notifications +- remember last path on upload +- share file to Talk room +- show local size in "on device" view +- SSO: add request header for deck app +- bug fixes + +For a full list, please see https://github.com/nextcloud/android/milestone/25 + +## 3.3.2 (November, 02, 2018) +- fix fingerprint not working on certain devices + +For a full list, please see https://github.com/nextcloud/android/milestone/27 + +## 3.3.1 (October, 29, 2018) +Bugfix release +- fix crash on shared folder/file via Talk +- fix crash on Notification activity +- fixed setup DAVdroid via settings +- hide edit option on shares, if not allowed + +For a full list, please see https://github.com/nextcloud/android/milestone/26 + +## 3.3.0 (September, 19, 2018) +- Support for Trashbin (Nc14+) +- Media streaming (Nc14+) +- New media detection for AutoUpload +- Improved TalkBack screenreader support +- Show outdated server warning for server In the Nextcloud community, participants from all over the world come together to create Free Software for a free internet. This is made possible by the support, hard work and enthusiasm of thousands of people, including those who create and use Nextcloud software. Our code of conduct offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6f37f85e4c6..4b77f2d17e22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,40 +1,57 @@ + # [Nextcloud](https://nextcloud.com) Android app -[![irc](https://img.shields.io/badge/IRC-%23nextcloud%20on%20freenode-orange.svg)](https://webchat.freenode.net/?channels=nextcloud) -[![irc](https://img.shields.io/badge/IRC-%23nextcloud--mobile%20on%20freenode-blue.svg)](https://webchat.freenode.net/?channels=nextcloud-mobile) - - # Index -1. Guidelines - 1. Issue reporting - 1. Labels - 1. PR - 1. Issue -1. Contributing to Source Code - 1. Developing process - 1. Android Studio formatter setup - 1. Contribution process - 1. Fork and download android/master repository - 1. Create pull request - 1. Create another pull request - 1. Translations -1. Releases - 1. Types - 1. Stable - 1. Release Candidate - 1. Dev - 1. Version Name and number - 1. Release cycle - 1. Release Process - 1. Stable - 1. Release Candidate - 1. Development Dev +1. [Guidelines](#guidelines) + 1. [Issue reporting](#issue-reporting) + 1. [Labels](#labels) + 1. [Pull request](#pull-request) + 1. [Issue](#issue) + 1. [Bug workflow](#bug-workflow) +1. [Contributing to Source Code](#contributing-to-source-code) + 1. [Developing process](#developing-process) + 1. [Branching model](#branching-model) + 1. [Formatter setup](#formatter-setup) + 1. [Build variants](#build-variants) + 1. [Git hooks](#git-hooks) + 1. [Contribution process](#contribution-process) + 1. [Fork and download android repository](#fork-and-download-android-repository) + 1. [Create pull request](#create-pull-request) + 1. [Create another pull request](#create-another-pull-request) + 1. [Backport pull request](#backport-pull-request) + 1. [Pull requests that also need changes on library](#pull-requests-that-also-need-changes-on-library) + 1. [Adding new files](#adding-new-files) + 1. [Testing](#testing) + 1. [File naming](#file-naming) + 1. [Menu files](#menu-files) + 1. [Translations](#translations) + 1. [Engineering practices](#engineering-practices) + 1. [Approach to technical debt](#approach-to-technical-debt) + 1. [Dependency injection](#dependency-injection) + 1. [Custom platform APIs](#custom-platform-apis) + 1. [Testing](#testing) +1. [Releases](#releases) + 1. [Types](#types) + 1. [Stable](#stable) + 1. [Release Candidate](#release-candidate) + 1. [Dev](#dev) + 1. [Version Name and number](#version-name-and-number) + 1. [Stable / Release candidate](#stable-release-candidate) + 1. [Dev](#dev) + 1. [Release cycle](#release-cycle) + 1. [Release Process](#release-process) + 1. [Stable Release](#stable-release) + 1. [Release Candidate Release](#release-candidate-release) + 1. [Development Dev](#development-dev) # Guidelines ## Issue reporting -* [Report the issue](https://github.com/nextcloud/android/issues/new) using our [template](https://github.com/nextcloud/android/blob/master/issue_template.md), it includes all the information we need to track down the issue. +* [Report the issue](https://github.com/nextcloud/android/issues/new/choose) and choose bug report or feature request. The template includes all the information we need to track down the issue. * This repository is *only* for issues within the Nextcloud Android app code. Issues in other components should be reported in their own repositories, e.g. [Nextcloud core](https://github.com/nextcloud/core/issues) * Search the [existing issues](https://github.com/nextcloud/android/issues) first, it's likely that your issue was already reported. If your issue appears to be a bug, and hasn't been reported, open a new issue. @@ -56,6 +73,7 @@ If your issue appears to be a bug, and hasn't been reported, open a new issue. ### Bug workflow +Every bug should be triaged in approved/needs info in a given time. * approved: at least one other is able to reproduce it * needs info: something unclear, or not able to reproduce * if no response within 1 months, bug will be closed @@ -89,36 +107,53 @@ We are all about quality while not sacrificing speed so we use a very pragmatic * Hot fixes not relevant for an upcoming feature release but the latest release can target the bug fix branch directly -### Android Studio formatter setup +### Formatter setup Our formatter setup is rather simple: * Standard Android Studio -* Line length 120 characters (Settings->Editor->Code Style->Right margin(columns): 120) +* Line length 120 characters (Settings->Editor->Code Style->Right margin(columns): 120; also set by EditorConfig * Auto optimize imports (Settings->Editor->Auto Import->Optimize imports on the fly) +You can fix Check / check (spotlessKotlinCheck) via following commands: + +```bash +./gradlew spotlessApply +./gradlew detekt +./gradlew spotlessCheck +./gradlew spotlessKotlinCheck +``` + +See section [Git hooks](#git-hooks) to have these run automatically with your commits. ### Build variants There are three build variants * generic: no Google Stuff, used for FDroid -* gplay: with Google Stuff (Push notification) and Analytics disabled, used for Google Play Store -* modified: custom, with Google Stuff and Analytics enabled, used for branded releases +* gplay: with Google Stuff (Push notification), used for Google Play Store +* versionDev: based on master and library master, available as direct download and FDroid + +### Git hooks +We provide git hooks to make development process easier for both the developer and the reviewers. +They are stored in [/scripts/hooks](/scripts/hooks) and can be installed with: + +```bash +./gradlew installGitHooks +``` ## Contribution process * Contribute your code in the branch 'master'. It will give us a better chance to test your code before merging it with stable code. * For your first contribution start a pull request on master. -### 1. Fork and download android/master repository: +### Fork and download android repository: * Please follow [SETUP.md](https://github.com/nextcloud/android/blob/master/SETUP.md) to setup Nextcloud Android app work environment. -### 2. Create pull request: -* Commit your changes locally: ```git commit -a``` +### Create pull request: +* Commit your changes locally. Remember to sign off your commits (`git commit -sm 'Your commit message'`). * Push your changes to your GitHub repo: ```git push``` -* Browse to https://github.com/YOURGITHUBNAME/android/pulls and issue pull request +* Browse to and issue pull request * Enter description and send pull request. - -### 3. Create another pull request: +### Create another pull request: To make sure your new pull request does not contain commits which are already contained in previous PRs, create a new branch which is a clone of upstream/master. * ```git fetch upstream``` @@ -127,9 +162,252 @@ To make sure your new pull request does not contain commits which are already co * Push branch to server: ```git push -u origin name_of_local_master_branch``` * Use GitHub to issue PR +### Backport pull request: +Use backport-bot via "/backport to stable-version", e.g. "/backport to stable-3.7". +This will automatically add "backport-request" label to PR and bot will create a new PR to targeted branch once the base PR is merged. +If automatic backport fails, it will create a comment. + +### Pull requests that also need changes on library +For speeding up developing, we do use a master snapshot of nextcloud-library, provided by jitpack.io. +This means that if a breaking change is merged on library, master branch of the app will fail. +To limit this risk please follow this approach: +- on app PR: first use a reference to your library branch in build.gradle: ext -> androidLibraryVersion, e.g. androidLibraryVersion = "changeSearch-SNAPSHOT" +- on library PR: use label "client change required" to indicate that this is breaking change. This will prevent GitHub from merging it. + +Once both PRs are reviewed and ready to merge: +- on library PR: remove label and merge it (for a short time now master cannot be built!) +- on app PR: change androidLibraryVersion back to "master-SNAPSHOT" +- wait for CI and then merge + +With this approach the "downtime" of not building master is limited to the timestamp between merge lib PR and merging app PR, which is only limited by CI. + +### Adding new files +If you create a new file it needs to contain a license header. We encourage you to use the same license (AGPL3+) as we do. +Copyright of Nextcloud GmbH is optional. + +Source code of library: +```java +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2024 Your Name + * SPDX-License-Identifier: MIT + */ + ``` + +Source code of app: +```java +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + ``` + + XML (layout) file: + ```xml + +``` + +### Testing +- testing is very important, but is lacking a lot on this project. Starting with 2020 we aim to write tests for every + new pull request. +- Code coverage can be found [here](https://codecov.io/gh/nextcloud/android). + +#### Unit tests +- small, isolated tests, with no need of Android SDK +- code coverage can be directly shown via right click on test and select "Run Test with Coverage" + +``` +./gradlew jacocoTestGplayDebugUnitTest +```bash + +#### Instrumented tests +- tests to see larger code working in correct way +- tests that require parts of Android SDK + +- run all tests ```./gradlew createGplayDebugCoverageReport -Pcoverage=true``` +- run selective test class: ```./gradlew createGplayDebugCoverageReport -Pcoverage=true + -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerContentProviderClientIT``` +- run multiple test classes: + - separate by "," + - ```./gradlew createGplayDebugCoverageReport -Pcoverage=true -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerContentProviderClientIT,com.nextcloud.client.FileDisplayActivityIT``` +- run one test in class: ```./gradlew createGplayDebugCoverageReport -Pcoverage=true + -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerContentProviderClientIT#saveFile``` +- JaCoCo results are shown as html: firefox ./build/reports/coverage/gplay/debug/index.html + +#### Instrumented tests with server communication +It is best to avoid server communication, see https://github.com/nextcloud/android/pull/3624. +But if a test requires a server, this is how it is done: +- Have a Nextcloud service reachable by your test device. This can be an existing server in the internet or a locally deployed one +as per the [server developer documentation](https://docs.nextcloud.com/server/latest/developer_manual/getting_started/devenv.html) +- Create a separate(!) test user on that server, otherwise the tests will infer with productive data. +- In `gradle.properties`, enter the URL, user name and password via the `NC_TEST_SERVER_...` attributes. + If you want to prevent an accidental commit of those, you can also store them in `~/.gradle/gradle.properties`. +- Your test class should inherit from `AbstractOnServerIT`, e.g.: `public class DownloadIT extends AbstractOnServerIT { ...` + Note that this will automatically delete all files after each test run, so you absolutely NEED a separate test user as mentioned above. +- All preconditions of your test regarding server data, e.g. existing files, need to be established by your test itself. + As a reference, see how `DownloadIT` first uploads the files it later tests the download with. +- Clean up these preconditions again, also in the failure case, using one or multiple `@After` methods in your test class. + +#### UI tests +We use [shot](https://github.com/Karumi/Shot) for taking screenshots and compare them. +To exclude the shot dependency from normal builds, the dependency needs to be activated via an environment variable `SHOT_TEST`. +For convenience, this and other prerequisites are encapsulated in utility scripts, so it is advised to use them +- check screenshots: ```scripts/androidScreenshotTest ``` + - check the script for a detailed documentation of the parameters +- update/generate new screenshots: ```scripts/updateScreenshots.sh ``` + - in this script are samples how to only execute a given class/test + - this will fire up docker & emulator to ensure that screenshots look the same +- creating own UI comparison tests: + - add IntentsTestRule for launching activity directly: + + ```java + @Rule public IntentsTestRule activityRule = new IntentsTestRule<>(SettingsActivity.class, + true, + false); + ``` + + - in test method: + + ```java + Activity activity = activityRule.launchActivity(null); + …do something, e.g. navigate, create folder, etc. … + Screenshot.snapActivity(activity).record(); + ``` + + - best practice is to first create test with emulator too see behaviour and then create screenshots + +## File naming + +The file naming patterns are inspired and based on [Ribot's Android Project And Code Guidelines](https://github.com/ribot/android-guidelines/blob/c1d8c9c904eb31bf01fe24aadb963b74281fe79a/project_and_code_guidelines.md). + +### Menu files + +Similar to layout files, menu files should match the name of the component. For example, if we are defining a menu file that is going to be used in the `UserProfileActivity`, then the name of the file should be `activity_user_profile.xml`. Same pattern applies for menus used in adapter view items, dialogs, etc. + +| Component | Class Name | Menu Name | +| ---------------- | ---------------------- | ----------------------------- | +| Activity | `UserProfileActivity` | `activity_user_profile.xml` | +| Fragment | `SignUpFragment` | `fragment_sign_up.xml` | +| Dialog | `ChangePasswordDialog` | `dialog_change_password.xml` | +| AdapterView item | --- | `item_person.xml` | +| Partial layout | --- | `partial_stats_bar.xml` | + +A good practice is to not include the word `menu` as part of the name because these files are already located in the `menu` directory. In case a component uses several menus in different places (via popup menus) then the resource name would be extended. For example, if the user profile activity has two popup menus for configuring the users settings and one for the handling group assignments then the file names for the menus would be: `activity_user_profile_user_settings.xml` and `activity_user_profile_group_assignments.xml`. ## Translations -We manage translations via [Transifex](https://www.transifex.com/nextcloud/nextcloud/android/). So just request joining the translation team for Android on the site and start translating. All translations will then be automatically pushed to this repository, there is no need for any pull request for translations. + +We manage translations via [Transifex](https://app.transifex.com/nextcloud/nextcloud/android/). So just request joining the translation team for Android on the site and start translating. All translations will then be automatically pushed to this repository, there is no need for any pull request for translations. + +If you need to change a translation, do not change it, but give it new key. This way the translation stays backward compatible as we automatically backport translated strings to last versions. + +When submitting PRs with changed translations, please only submit changes to values/strings.xml and not changes to translated files. These will be overwritten by next merge of transifex-bot and increase PR review. + +## Engineering practices + +This section contains some general guidelines for new contributors, based on common issues flagged during code review. + +### Approach to technical debt + +TL;DR Non-Stop Litter Picking Party! + +We recognize the importance of technical debt that can slow down development, make bug fixing difficult and +discourage future contributors. + +We are mindful of the [Broken Windows Theory](https://en.wikipedia.org/wiki/Broken_windows_theory) and we'd like +actively promote and encourage contributors to apply The Scout's Rule: *"Always leave the campground cleaner than +you found it"*. Simple, little improvements will sum up and will be very appreciated by Nextcloud team. + +We also promise to actively support and mentor contributors that help us to improve code quality, as we understand +that this process is challenging and requires deep understanding of the application codebase. + +### Dependency injection + +TL;DR Avoid calling constructors inside constructors. + +In effort to modernize the codebase we are applying [Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) +whenever possible. We use 2 approaches: automatic and manual. + +We are using [Dagger 2](https://dagger.dev/) to inject dependencies into major Android components only: + + * `Activity` + * `Fragment` + * `Service` + * `BroadcastReceiver` + * `ContentProvider` + +This process is fairly automatic, with `@Inject` annotation being sufficient to supply properly initialized +objects. Android lifecycle callbacks allow us to do most of the work without effort. + +For other application sub-components we prefer to use constructor injection and manually provide required dependencies. + +This combination allows us to benefit from automation when it provides most value, does not tie rest of the code +to any specific framework and stimulates continuous code modernization through iterative refactoring of all minor +elements. + +### Custom platform APIs + +TL;DR Avoid Android platform APIs. + +Nextcloud Android application provides some replacements for native Android APIs to facilitate testing +and expose higher-level, business-specific APIs. + +Generally, whenever you need: + +* account management +* application preferences +* background task scheduling +* device hardware information +* media playback +* networking +* logging +* notifications management + +we have something more suitable. + +Our transition to new APIs is a continuous process. Contributors might be asked by code reviewers to +refrain from using specific Android APIs considered problematic and to use Nextcloud APIs instead. +In extreme cases we might decide to put specific features on hold until we provide platform API +replacement. + +If in doubt, ask Nextcloud developers. App undergoes a process of intense refactoring and situation +changes frequently. + +### Testing + +TL;DR If we can't write a test for it, it's not good. + +Test automation is challenging in mobile applications in general. We try to improve in this area +and thereof we'd ask contributors to be mindful of their code testability: + +1. new code submitted to Nextcloud project should be provided with automatic tests +2. contributions to existing code that is currently not covered by automatic tests + should at least not make future efforts more challenging +3. whenever possible, testability should be improved even if the code is not covered by tests + +### Performance + +If you're interested in improving the app's performance, please check the [official documentation](https://developer.android.com/topic/performance) +for ways you can inspect and improve performance. + +For additional analysis, set the `perfAnalysis` property +in your Gradle build: + +```shell +./gradlew installGplayDebug -P perfAnalysis +``` + +This will install the app with [LeakCanary](https://square.github.io/leakcanary/) and +[StrictMode](https://developer.android.com/reference/android/os/StrictMode) enabled and configured. +These tools can help find memory leaks, foreground operations that should be in background, and other performance +problems. # Releases At the moment we are releasing the app in two app stores: @@ -172,17 +450,19 @@ Examples for different versions: * 8.12.2 ```80120200``` * 9.8.4-rc18 ```90080418``` -beware, that beta releases for an upcoming version will always use the minor and hotfix version of the release they are targeting. So to make sure the version code of the upcoming stable release will always be higher stable releases set the 2 beta digits to '99' as seen above in the examples. +beware, that beta releases for an upcoming version will always use the minor and hotfix version of the release they are targeting. So to make sure the version code of the upcoming stable release will always be higher stable releases set the 2 beta digits to '99' as seen above in the examples. For major versions, as we're not a library and thus 'incompatible API changes' is not something that happens, decisions are essentially marketing-based. If we deem a release to be very impactful, we might increase the major version number. ### Dev For dev the version name is in format YYYYMMDD. It is mainly as a reference for reporting bugs and is not related to stable/release candidates as it is an independent app. ## Release cycle -* for each release we choose several PRs that will be included in the next release. Currently there are many open PRs from ownCloud, but after merging them, the intention is to choose the PRs that are ready (reviewed, tested) to get them merged very soon. -* these will be merged into master, tested heavily, maybe automatic testing +* Releases are planned every ~2 months, with 6 weeks of developing and 2 weeks of stabilising * after feature freeze a public release candidate on play store and f-droid is released * ~2 weeks testing, bug fixing * release final version on f-droid and play store +* Bugfix releases (dot releases, e.g. 3.2.1) are released 4 weeks after stable version from the branch created with first stable release (stable-3.2.x). If changes to the library are required, we do the same: create a branch from the version used in stable release (e.g. 1.1.0) and then release a dot release (1.1.1). + +> Hotfixes as well as security fixes are released via bugfix releases (dot releases) but are released on demand in contrast to regular, scheduled bugfix releases. To get an idea which PRs and issues will be part of the next release simply check our [milestone plan](https://github.com/nextcloud/android/milestones) @@ -203,7 +483,7 @@ Release Candidate releases are based on the git [master](https://github.com/next 2. Create a [release/tag](https://github.com/nextcloud/android/releases) in git. Tag name following the naming schema: ```rc-Mayor.Minor.Hotfix-betaIncrement``` (e.g. rc-1.2.0-12) naming the version number following the [semantic versioning schema](http://semver.org/) -### Dev Release +### Developement Release Dev releases are based on the [master](https://github.com/nextcloud/android/tree/master) branch and are done independently from stable releases for people willing to test new features and provide valuable feedback on new features to be incorporated before a feature gets released in the stable app. The deployment/build is done once a day automatically. If code has changed a new apk will be published [here](https://download.nextcloud.com/android/dev) and it will, with a little delay, be available on [Fdroid](https://f-droid.org/repository/browse/?fdfilter=nextcloud&fdid=com.nextcloud.android.beta). diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000000..b734015f8209 --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +source "https://rubygems.org" + +gem 'fastlane' + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.license b/Gemfile.license new file mode 100644 index 000000000000..7a2ef8e0aa05 --- /dev/null +++ b/Gemfile.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: CC0-1.0 diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000000..36d96e16fd37 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,233 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1237.0) + aws-sdk-core (3.244.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.123.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.219.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + bigdecimal (3.3.1) + cgi (0.4.2) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.5) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.229.0) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-huawei_appgallery_connect (1.0.31) + cgi + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.6.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.19.3) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.20.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + optparse (0.8.1) + os (1.1.4) + plist (3.7.2) + public_suffix (6.0.2) + rake (13.3.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.4.1) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + fastlane + fastlane-plugin-huawei_appgallery_connect + +BUNDLED WITH + 2.5.4 diff --git a/Gemfile.lock.license b/Gemfile.lock.license new file mode 100644 index 000000000000..7a2ef8e0aa05 --- /dev/null +++ b/Gemfile.lock.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: CC0-1.0 diff --git a/ICONS.txt b/ICONS.txt deleted file mode 100644 index 3dffdb83c1d1..000000000000 --- a/ICONS.txt +++ /dev/null @@ -1,99 +0,0 @@ -Standard Google Material Design icons -Copyright (c) 2014, Google (http://www.google.com/design/) -uses the license at https://github.com/google/material-design-icons/blob/master/LICENSE - -Twitter icon graphic -Copyright (c) 2014, Austin Andrews (http://materialdesignicons.com/), -with Reserved Font Name Material Design Icons. - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt new file mode 100644 index 000000000000..0c97efd25b59 --- /dev/null +++ b/LICENSES/AGPL-3.0-or-later.txt @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 000000000000..137069b82387 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSES/BSD-2-Clause.txt b/LICENSES/BSD-2-Clause.txt new file mode 100644 index 000000000000..5f662b354cd4 --- /dev/null +++ b/LICENSES/BSD-2-Clause.txt @@ -0,0 +1,9 @@ +Copyright (c) + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 000000000000..0e259d42c996 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/LICENSES/GPL-2.0-only.txt b/LICENSES/GPL-2.0-only.txt new file mode 100644 index 000000000000..17cb286430a4 --- /dev/null +++ b/LICENSES/GPL-2.0-only.txt @@ -0,0 +1,117 @@ +GNU GENERAL PUBLIC LICENSE +Version 2, June 1991 + +Copyright (C) 1989, 1991 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. + +To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + +We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. + +Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. + +Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. + +1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. + + c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. + +4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. + +6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. + +7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. + +10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author + + This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. + +signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice diff --git a/LICENSES/GPL-3.0-or-later.txt b/LICENSES/GPL-3.0-or-later.txt new file mode 100644 index 000000000000..f6cdd22a6c1f --- /dev/null +++ b/LICENSES/GPL-3.0-or-later.txt @@ -0,0 +1,232 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright © 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble + +The GNU General Public License is a free, copyleft license for software and other kinds of works. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS + +0. Definitions. + +“This License” refers to version 3 of the GNU General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based on the Program. + +To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. + +A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. +A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Use with the GNU Affero General Public License. +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . + +The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . diff --git a/LICENSES/LGPL-2.1-or-later.txt b/LICENSES/LGPL-2.1-or-later.txt new file mode 100644 index 000000000000..c9aa53018e76 --- /dev/null +++ b/LICENSES/LGPL-2.1-or-later.txt @@ -0,0 +1,175 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/LICENSES/LicenseRef-NextcloudTrademarks.txt b/LICENSES/LicenseRef-NextcloudTrademarks.txt new file mode 100644 index 000000000000..464a30b58bdc --- /dev/null +++ b/LICENSES/LicenseRef-NextcloudTrademarks.txt @@ -0,0 +1,9 @@ +The Nextcloud marks +Nextcloud and the Nextcloud logo is a registered trademark of Nextcloud GmbH in Germany and/or other countries. +These guidelines cover the following marks pertaining both to the product names and the logo: “Nextcloud” +and the blue/white cloud logo with or without the word Nextcloud; the service “Nextcloud Enterprise”; +and our products: “Nextcloud Files”; “Nextcloud Groupware” and “Nextcloud Talk”. +This set of marks is collectively referred to as the “Nextcloud marks.” + +Use of Nextcloud logos and other marks is only permitted under the guidelines provided by the Nextcloud GmbH. +A copy can be found at https://nextcloud.com/trademarks/ diff --git a/LICENSES/LicenseRef-XTrademarks.txt b/LICENSES/LicenseRef-XTrademarks.txt new file mode 100644 index 000000000000..46b698359da0 --- /dev/null +++ b/LICENSES/LicenseRef-XTrademarks.txt @@ -0,0 +1,49 @@ +Trademark policy +April 2023 + + +You may not violate others’ intellectual property rights, including copyright and trademark. + +A trademark is a word, logo, phrase, or device that distinguishes a trademark holder’s good or service in the marketplace. Trademark law may prevent others from using a trademark in an unauthorized or confusing manner. + + +What is in violation of this policy? + +Using another’s trademark in a way that may mislead or confuse people about your affiliation may be a violation of our trademark policy. + + +What is not a violation of this policy? + +Referencing another’s trademark is not automatically a violation of X's trademark policy. Examples of non-violations include: + +* using a trademark in a way that is outside the scope of the trademark registration e.g., in a different territory, or a different class of goods or services than that identified in the registration; and +* using a trademark in a nominative or other fair use manner. For more information, see our Misleading and deceptive identities policy (https://help.twitter.com/en/rules-and-policies/twitter-impersonation-and-deceptive-identities-policy.html). + + +Who can report violations of this policy? + +X only investigates requests that are submitted by the trademark holder or their authorized representative e.g., a legal representative or other representative for a brand. + + +How can I report violations of this policy? + +You can submit a trademark report through our trademark report form (https://help.twitter.com/forms/trademark). Please provide all the information requested in the form. If you submit an incomplete report, we’ll need to follow up about the missing information. Please note that this will result in a delay in processing your report. + +Note: We may provide the account holder with your name and other information included in the copy of the report. + + +What happens if you violate this policy? + +If we determine that you violated our trademark policy, we may suspend your account. Depending on the type of violation, we may give you an opportunity to comply with our policies. In other instances, an account may be permanently suspended upon first review. If you believe that your account was suspended in error, you can submit an appeal (https://help.twitter.com/forms/general?subtopic=suspended). + + +Additional resources + +Learn more about our range of enforcement options (https://help.twitter.com/rules-and-policies/enforcement-options) and our approach to policy development and enforcement (https://help.twitter.com/rules-and-policies/enforcement-philosophy). + + +Legal disclaimer + +By using the X trademarks and resources on this site, you agree to follow the X Trademark Guidelines in our Brand Guidelines — as well as our Terms of Service and all other X rules and policies. If you have any questions, contact us at trademarks@x.com. + +A copy can be found at https://about.x.com/en/who-we-are/brand-toolkit and https://help.twitter.com/en/rules-and-policies/x-trademark-policy diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 000000000000..2071b23b0e08 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index b7bc9908cdba..1a23a124eb06 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,123 @@ -# [Nextcloud](https://nextcloud.com) Android app + +# [Nextcloud](https://nextcloud.com) Android app :iphone: -[![Build Status](https://drone.nextcloud.com/api/badges/nextcloud/android/status.svg)](https://drone.nextcloud.com/nextcloud/android) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/80401cb343854343b4d94acbfb72d3ec)](https://www.codacy.com/app/Nextcloud/android?utm_source=github.com&utm_medium=referral&utm_content=nextcloud/android&utm_campaign=Badge_Grade) [![Releases](https://img.shields.io/github/release/nextcloud/android.svg)](https://github.com/nextcloud/android/releases/latest) [![irc](https://img.shields.io/badge/IRC-%23nextcloud--mobile%20on%20freenode-blue.svg)](https://webchat.freenode.net/?channels=nextcloud-mobile) +[![REUSE status](https://api.reuse.software/badge/github.com/nextcloud/android)](https://api.reuse.software/info/github.com/nextcloud/android) [![Build Status](https://drone.nextcloud.com/api/badges/nextcloud/android/status.svg)](https://drone.nextcloud.com/nextcloud/android) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/fb4cf26336774ee3a5c9adfe829c41aa)](https://app.codacy.com/gh/nextcloud/android/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Releases](https://img.shields.io/github/release/nextcloud/android.svg)](https://github.com/nextcloud/android/releases/latest) [Download from Google Play](https://play.google.com/store/apps/details?id=com.nextcloud.client) +alt="Download from Google Play" +height="80">](https://play.google.com/store/apps/details?id=com.nextcloud.client) [Get it on F-Droid](https://f-droid.org/packages/com.nextcloud.client/) +alt="Get it on F-Droid" +height="80">](https://f-droid.org/packages/com.nextcloud.client/) +[](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.nextcloud.client%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fnextcloud%2Fandroid%22%2C%22author%22%3A%22nextcloud%22%2C%22name%22%3A%22Nextcloud%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Enextcloud.*%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22Nextcloud%20ist%20eine%20Cloudanwendung%2C%20die%20selbst%20gehostet%20werden%20kann.%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Atrue%7D%22%2C%22overrideSource%22%3Anull%7D) + +Signing certificate fingerprint to [verify](https://developer.android.com/studio/command-line/apksigner#usage-verify) the APK using the official Android documentation. +- APK with "gplay" name, found [here](https://github.com/nextcloud/android/releases) or distributed via Google Play Store +- APK with "nextcloud", found [here](https://github.com/nextcloud/android/releases) +- not suitable for Fdroid downloads, as Fdroid is signing it on their own +``` +SHA-256: fb009522f65e25802261b67b10a45fd70e610031976f40b28a649e152ded0373 +SHA-1: 74aa1702e714941be481e1f7ce4a8f779c19dcea +``` **The Android client for [Nextcloud](https://nextcloud.com). Easily work with your data on your Nextcloud.** ![App screenshots](/doc/Nextcloud_Android_Screenshots.png "App screenshots") -## How to contribute -If you want to [contribute](https://nextcloud.com/contribute/) to Nextcloud, you are very welcome: +## Getting help :rescue\_worker\_helmet: + +Note: The section *Known Problems / FAQs* below may already document your situation. + +If you need assistance or want to ask a question about the Android app, you are welcome to [ask for support](https://help.nextcloud.com/c/clients/android) in the [Nextcloud Help Forum](https://help.nextcloud.com). If you have found a probable bug or have an enhancement idea, feel free to [open a new Issue on GitHub](https://github.com/nextcloud/android/issues). + +If you're not sure if something is a bug or a configuration matter (with your client, server, proxy, etc.), the [Nextcloud Help Forum](https://help.nextcloud.com) is probably the best place to start so that you can get feedback (you can always return here, after getting feedback there, to report a suspected bug). -- on our IRC channels [![irc](https://img.shields.io/badge/IRC-%23nextcloud%20on%20freenode-orange.svg)](https://webchat.freenode.net/?channels=nextcloud) and [![irc](https://img.shields.io/badge/IRC-%23nextcloud--mobile%20on%20freenode-blue.svg)](https://webchat.freenode.net/?channels=nextcloud-mobile) on freenode -- our forum at https://help.nextcloud.com -- for translations of the app on [Transifex](https://www.transifex.com/nextcloud/nextcloud/android/) -- opening issues and PRs (including a corresponding issue) +Keep in mind, that this repository only manages the Android app. If you find bugs or have problems with the server/backend, you should use the Nextcloud Help Forum to ask for help or report the bug to the [Nextcloud server team](https://github.com/nextcloud/server)! -## Contribution Guidelines & License +## How to contribute :rocket: + +If you want to [contribute](https://nextcloud.com/contribute/) to the Nextcloud Android client app, there are many ways to help whether or not you are a coder: + +* helping out other users on our forum at https://help.nextcloud.com +* providing translations of the app on [Transifex](https://app.transifex.com/nextcloud/nextcloud/android/) +* reporting problems / suggesting enhancements by [opening new issues](https://github.com/nextcloud/android/issues/new/choose) +* implementing proposed bug fixes and enhancement ideas by submitting PRs (associated with a corresponding issue preferably) +* reviewing [pull requests](https://github.com/nextcloud/android/pulls) and providing feedback on code, implementation, and functionality +* Add [automated tests](CONTRIBUTING.md#testing) for existing functionality +* installing and testing [pull request builds](https://github.com/nextcloud/android/pulls), [daily/dev builds](https://github.com/nextcloud/android#development-version-hammer), or [RCs/release candidate builds](https://github.com/nextcloud/android/releases) +* enhancing Admin, User, or Developer [documentation](https://github.com/nextcloud/documentation/) +* hitting hard on the latest stable release by testing fundamental features and evaluating the user experience +* proactively getting familiar with [how to gather debug logs](https://github.com/nextcloud/android#getting-debug-info-via-logcat-mag) from your devices (so that you are prepared to provide a detailed report if you encounter a problem with the app in the future) + +## Contribution Guidelines & License :scroll: [GPLv2](https://github.com/nextcloud/android/blob/master/LICENSE.txt). All contributions to this repository from June, 16 2016 on are considered to be licensed under the AGPLv3 or any later version. Nextcloud doesn't require a CLA (Contributor License Agreement). The copyright belongs to all the individual contributors. Therefore we recommend that every contributor adds following line to the header of a file, if they changed it substantially: -``` -@copyright Copyright (c) , () -``` + SPDX-FileCopyrightText: Please read the [Code of Conduct](https://nextcloud.com/community/code-of-conduct/). This document offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other. Please review the [guidelines for contributing](https://github.com/nextcloud/android/blob/master/CONTRIBUTING.md) to this repository. -More information how to contribute: [https://nextcloud.com/contribute/](https://nextcloud.com/contribute/) +More information on how to contribute: + +## Start contributing :hammer\_and\_wrench: -## Start contributing Make sure you read [SETUP.md](https://github.com/nextcloud/android/blob/master/SETUP.md) and [CONTRIBUTING.md](https://github.com/nextcloud/android/blob/master/CONTRIBUTING.md) before you start working on this project. But basically: fork this repository and contribute back using pull requests to the master branch. -Easy starting points are also reviewing [pull requests](https://github.com/nextcloud/android/pulls) and working on [starter issues](https://github.com/nextcloud/android/issues?q=is%3Aopen+is%3Aissue+label%3A%22starter+issue%22). +Easy starting points are also reviewing [pull requests](https://github.com/nextcloud/android/pulls) and working on [starter issues](https://github.com/nextcloud/android/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22). + +## Logs + +### Getting debug info via logcat :mag: -### Getting debug info via logcat -#### With a computer: -- connect the device via USB -- open command prompt/terminal -- enter `adb logcat > logcatOutput.txt` to save the output to this file +#### With a linux computer: + +* enable USB-Debugging in your smartphones developer settings and connect it via USB +* open command prompt/terminal +* enter `adb logcat --pid=$(adb shell pidof -s 'com.nextcloud.client') > logcatOutput.txt` to save the output to this file **Note:** You must have [adb](https://developer.android.com/studio/releases/platform-tools.html) installed first! -#### On a device (with root) -- open terminal app *(can be enabled in developer options)* -- get root access via "su" -- enter `logcat -d -f /sdcard/logcatOutput.txt` +#### On Windows: + +* download and install [Minimal ADB and fastboot](https://forum.xda-developers.com/t/tool-minimal-adb-and-fastboot-2-9-18.2317790/#post-42407269) +* enable USB-Debugging in your smartphones developer settings and connect it via USB +* launch Minimal ADB and fastboot +* enter `adb shell pidof -s 'com.nextcloud.client'` and use the output as `` in the following command: +* `adb logcat --pid= > "%USERPROFILE%\Downloads\logcatOutput.txt"` (This will produce a `logcatOutput.txt` file in your downloads) +* if the processID is `18841`, an example command is: `adb logcat --pid=18841 > "%USERPROFILE%\Downloads\logcatOutput.txt"` (You might cancel the process after a while manually: it will not be exited automatically.) +* For a PowerShell terminal, replace `%USERPROFILE%` with `$env:USERPROFILE` in the commands above. -or +#### On a device (with root) :wrench: -- use [CatLog](https://play.google.com/store/apps/details?id=com.nolanlawson.logcat) or [aLogcat](https://play.google.com/store/apps/details?id=org.jtb.alogcat) +* open terminal app *(can be enabled in developer options)* +* get root access via "su" +* enter `logcat -d --pid $(pidof -s com.nextcloud.client) -f /sdcard/logcatOutput.txt` + +or + +* use [CatLog](https://play.google.com/store/apps/details?id=com.nolanlawson.logcat) or [aLogcat](https://play.google.com/store/apps/details?id=org.jtb.alogcat) **Note:** Your device needs to be rooted for this approach! -## Development version -- [APK (direct download)](https://download.nextcloud.com/android/dev/latest.apk) -- [F-Droid](https://f-droid.org/repository/browse/?fdfilter=nextcloud&fdid=com.nextcloud.android.beta) +## Development version :hammer: + +* [APK (direct download)](https://download.nextcloud.com/android/dev/latest.apk) +* [F-Droid](https://f-droid.org/en/packages/com.nextcloud.android.beta/) + +## Known Problems and FAQs -## Support +### Push notifications do not work on F-Droid editions -If you need assistance or want to ask a question about the Android app, you are welcome to [ask for support](https://help.nextcloud.com/c/clients/android) in our Forums or the [IRC-Channel](https://webchat.freenode.net/?channels=nextcloud-mobile). If you have found a bug, feel free to [open a new Issue on GitHub](https://github.com/nextcloud/android/issues). Keep in mind, that this repository only manages the Android app. If you find bugs or have problems with the server/backend, you should ask the [Nextcloud server team](https://github.com/nextcloud/server) for help! +Push Notifications are not currently supported in the F-Droid builds due to dependencies on Google Play services. -## Remarks +## Remarks :scroll: Google Play and the Google Play logo are trademarks of Google Inc. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 000000000000..428de14d9e48 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +version = 1 +SPDX-PackageName = "Nextcloud Android" +SPDX-PackageSupplier = "Nextcloud Android team " +SPDX-PackageDownloadLocation = "https://github.com/nextcloud/android" + +[[annotations]] +path = "gradle/wrapper/gradle-wrapper.jar" +precedence = "aggregate" +SPDX-FileCopyrightText = "2015-2021 the original authors" +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = ["user_manual/images/android-1.png", "user_manual/images/android-2.png", "user_manual/images/android-3.png", "user_manual/images/android-4.png", "user_manual/images/android-10.png", "user_manual/images/davdroid-1-button-in-nextcloud-app.png", "user_manual/images/davdroid-2-install-davdroid.png", "user_manual/images/davdroid-3-enter-password.png", "user_manual/images/davdroid-4-specify-owner-email.png"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2016-2024 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + +[[annotations]] +path = ["user_manual/conf.py", "user_manual/android_app.rst", "user_manual/index.rst", "user_manual/conf.py", "user_manual/Makefile"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2015-2016 ownCloud Inc., 2016-2024 Nextcloud GmbH" +SPDX-License-Identifier = "GPL-2.0-only" + +[[annotations]] +path = ["user_manual/images/android-11.png", "user_manual/images/android-12.png", "user_manual/images/android-13.png", "user_manual/images/android-14.png", "user_manual/images/android-15.png", "user_manual/images/android-5.png", "user_manual/images/android-6.png", "user_manual/images/android-8.png", "user_manual/images/android-9.png"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2015-2016 ownCloud Inc." +SPDX-License-Identifier = "GPL-2.0-only" + +[[annotations]] +path = ["app/src/**/res/mipmap-**dpi/ic_launcher.png", "app/src/**/ic_launcher-web.png", "src/**/fastlane/metadata/en-US/images/*.png", "src/generic/fastlane/metadata/android/en-US/images/icon.png", "src/versionDev/fastlane/metadata/android/en-US/images/icon.png", "app/src/main/ic_launcher-web-round.png"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2017-2025 Nextcloud GmbH " +SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks" + +[[annotations]] +path = [".idea/**", "app/schemas/com.nextcloud.client.database.NextcloudDatabase/**.json", "app/screenshots/generic/debug/**.png", "app/src/main/res/values-**/strings.xml", "src/**/fastlane/metadata/**/*.txt", "src/versionDev/fastlane/metadata/android/**/changelogs/**.txt", "app/src/androidTest/assets/**", "app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker", "app/src/**/google-services.json", "app/src/main/res/drawable-**dpi/checker_16_16.png", "app/src/main/res/raw/encryption_key_words.txt", "app/src/main/resources/ical4j.properties", "app/src/main/res/drawable-**dpi/apk.png", "app/src/main/res/drawable-**dpi/fdroid.png", "app/src/main/res/drawable-**dpi/playstore.png", "app/src/main/res/drawable-**dpi/background.png", "app/src/main/res/drawable-**dpi/background_nc18.png"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2016-2025 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/Readme-AR.md b/Readme-AR.md new file mode 100644 index 000000000000..98735aa56eb1 --- /dev/null +++ b/Readme-AR.md @@ -0,0 +1,120 @@ + +# تطبيق [Nextcloud](https://nextcloud.com)لأجهزة أندرويد 📱 + +[![حالة REUSE](https://api.reuse.software/badge/github.com/nextcloud/android)](https://api.reuse.software/info/github.com/nextcloud/android) +[![حالة البناء](https://drone.nextcloud.com/api/badges/nextcloud/android/status.svg)](https://drone.nextcloud.com/nextcloud/android) +[![تقييم Codacy](https://app.codacy.com/project/badge/Grade/fb4cf26336774ee3a5c9adfe829c41aa)](https://app.codacy.com/gh/nextcloud/android/dashboard) +[![الإصدارات](https://img.shields.io/github/release/nextcloud/android.svg)](https://github.com/nextcloud/android/releases/latest) + +[تحميل من Google Play](https://play.google.com/store/apps/details?id=com.nextcloud.client) +[احصل عليه من F-Droid](https://f-droid.org/packages/com.nextcloud.client/) + +## التحقق من توقيع التطبيق 🔐 + +للتأكد من صحة ملف APK: + +- ملف APK باسم "gplay" متوفر [هنا](https://github.com/nextcloud/android/releases) أو عبر متجر Google Play +- ملف APK باسم "nextcloud" متوفر [هنا](https://github.com/nextcloud/android/releases) +- غير مناسب لتحميلات F-Droid، لأن F-Droid يقوم بتوقيعه بنفسه + +```plaintext +SHA-256: fb009522f65e25802261b67b10a45fd70e610031976f40b28a649e152ded0373 +SHA-1: 74aa1702e714941be481e1f7ce4a8f779c19dcea +``` + +**تطبيق Nextcloud لأندرويد يتيح لك إدارة بياناتك بسهولة على خادم Nextcloud الخاص بك.** + +## الحصول على الدعم 🆘 + +إذا واجهت مشكلة أو لديك سؤال، يمكنك زيارة [منتدى الدعم](https://help.nextcloud.com/c/clients/android). +إذا اكتشفت خطأ أو لديك اقتراح لتحسين التطبيق، يمكنك [فتح قضية جديدة على GitHub](https://github.com/nextcloud/android/issues). + +إذا لم تكن متأكدًا ما إذا كانت المشكلة ناتجة عن التطبيق أو الإعدادات أو الخادم، فابدأ بالسؤال في المنتدى، ثم عد إلى GitHub إذا لزم الأمر. + +> ملاحظة: هذا المستودع خاص بتطبيق أندرويد فقط. إذا كانت المشكلة في الخادم، يرجى التواصل مع [فريق خادم Nextcloud](https://github.com/nextcloud/server). + +## كيف تساهم في المشروع 🚀 + +هناك العديد من الطرق للمساهمة، سواء كنت مبرمجًا أو لا: + +- مساعدة المستخدمين في المنتدى: https://help.nextcloud.com +- ترجمة التطبيق عبر [Transifex](https://app.transifex.com/nextcloud/nextcloud/android/) +- الإبلاغ عن المشاكل أو تقديم اقتراحات عبر [GitHub Issues](https://github.com/nextcloud/android/issues/new/choose) +- تنفيذ إصلاحات أو تحسينات عبر Pull Requests +- مراجعة [طلبات الدمج](https://github.com/nextcloud/android/pulls) +- اختبار النسخ التجريبية أو اليومية أو المرشحة للإصدار +- تحسين [التوثيق](https://github.com/nextcloud/documentation/) +- اختبار الميزات الأساسية في آخر إصدار مستقر +- تعلم كيفية جمع سجلات الأخطاء (logcat) لتقديم تقارير دقيقة + +## إرشادات المساهمة والترخيص 📜 + +- الترخيص: [GPLv2](https://github.com/nextcloud/android/blob/master/LICENSE.txt) +- جميع المساهمات بعد 16 يونيو 2016 تعتبر مرخصة تحت AGPLv3 أو أي إصدار لاحق +- لا حاجة لتوقيع اتفاقية مساهم (CLA) +- يُفضل إضافة السطر التالي في رأس الملف عند إجراء تغييرات كبيرة: + +```plaintext +SPDX-FileCopyrightText: <السنة> <اسمك> <بريدك الإلكتروني> +``` + +يرجى قراءة [مدونة السلوك](https://nextcloud.com/community/code-of-conduct/) لضمان بيئة تعاون إيجابية. +راجع أيضًا [إرشادات المساهمة](https://github.com/nextcloud/android/blob/master/CONTRIBUTING.md). + +## ابدأ بالمساهمة 🔧 + +- اقرأ [SETUP.md](https://github.com/nextcloud/android/blob/master/SETUP.md) و[CONTRIBUTING.md](https://github.com/nextcloud/android/blob/master/CONTRIBUTING.md) +- قم بعمل fork للمستودع وابدأ بإرسال Pull Requests إلى فرع master +- يمكنك البدء بمراجعة [طلبات الدمج](https://github.com/nextcloud/android/pulls) أو العمل على [القضايا المبتدئة](https://github.com/nextcloud/android/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) + +## جمع سجلات الأخطاء (logcat) 🔍 + +### على لينكس: + +- فعّل USB-Debugging على هاتفك +- افتح الطرفية وأدخل: + +```bash +adb logcat --pid=$(adb shell pidof -s 'com.nextcloud.client') > logcatOutput.txt +``` + +> تأكد من تثبيت [adb](https://developer.android.com/studio/releases/platform-tools.html) + +### على ويندوز: + +- حمّل [Minimal ADB and Fastboot](https://forum.xda-developers.com/t/tool-minimal-adb-and-fastboot-2-9-18.2317790/#post-42407269) +- فعّل USB-Debugging +- افتح البرنامج وأدخل: + +```bash +adb shell pidof -s 'com.nextcloud.client' +``` + +- استخدم الناتج كـ `` في الأمر التالي: + +```bash +adb logcat --pid= > "%USERPROFILE%\Downloads\logcatOutput.txt" +``` + +### على الجهاز (مع صلاحيات root): + +```bash +su +logcat -d --pid $(pidof -s com.nextcloud.client) -f /sdcard/logcatOutput.txt +``` + +أو استخدم تطبيقات مثل [CatLog](https://play.google.com/store/apps/details?id=com.nolanlawson.logcat) أو [aLogcat](https://play.google.com/store/apps/details?id=org.jtb.alogcat) + +## النسخة التطويرية 🛠️ + +- [تحميل مباشر للـ APK](https://download.nextcloud.com/android/dev/latest.apk) +- [F-Droid النسخة التجريبية](https://f-droid.org/en/packages/com.nextcloud.android.beta/) + +## المشاكل المعروفة والأسئلة الشائعة + +### الإشعارات الفورية لا تعمل في نسخ F-Droid + +بسبب اعتمادها على خدمات Google Play، لا تعمل الإشعارات الفورية في نسخ F-Droid حاليًا. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000000..b0adf5772082 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,71 @@ + +# Security Policy + +# 💡 TLDR: Report issues at [hackerone.com/nextcloud](https://hackerone.com/nextcloud) + +# Security Policy + +[Security](https://nextcloud.com/security/) is very important to us. + +If you believe you have found a security vulnerability that meets our definition of a security +vulnerability, please report is as described below. + +## Context + +Please review our [threat model and accepted risks](https://nextcloud.com/security/threat-model) to learn what +is currently considered a security vulnerability versus expected behavior. And review what is considered +[in scope or bounty eligible](https://hackerone.com/nextcloud/policy_scopes). + + +## Reporting a Vulnerability + +**⚠️ Please do _not_ report security vulnerabilities through public GitHub issues.** + +If you have discovered a security matter with Nextcloud, please read our +[responsible disclosure guidelines](https://nextcloud.com/security/) and contact us at +[hackerone.com/nextcloud](https://hackerone.com/nextcloud). + +Your report should include: + +- Product version +- A vulnerability description +- Reproduction steps +- Any other details you think are likely to be important + +### What to Expect + +You should receive an initial acknowledgement within 24 hours in most cases. + +A member of the security team will confirm the vulnerability, determine its impact, follow-up with any questions, +and coordinate the fix and publication. + +The fix will be applied to all applicable and still supported stable branches, tested, and packaged in the next security release. +The vulnerability will be publicly announced after the release. Finally, your name will be added +to the [hall of fame](https://hackerone.com/nextcloud/thanks) as a thank you from the entire Nextcloud +community. + +If the vulnerability involves an app that is not maintained by Nextcloud (i.e. hosted by the +Nextcloud project but community maintained, or hosted elsewhere), the security team will try to coordinate with the +current maintainer and help to get the issue fixed in similar fashion. + +### Bug Bounties + +If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Details +on past bounty ranges can be found at [hackerone.com/nextcloud](https://hackerone.com/nextcloud). + +## Existing Security Advisories + +Published security advisories for the Nextcloud Server, Clients and Apps can be viewed at +[https://github.com/nextcloud/security-advisories/security/advisories](https://github.com/nextcloud/security-advisories/security/advisories). + +## Supported Versions + +Only the latest version is supported. We release every second month a feature release (currently 3.x) and inbetween a bug fix release (3.x.y). + +## Additional Information + +Please visit [https://nextcloud.com/security/](https://nextcloud.com/security/) for further information about Nextcloud security. +Please visit [https://nextcloud.com/security/threat-model](https://nextcloud.com/security/threat-model) for our threat model and accepted risks. diff --git a/SETUP.md b/SETUP.md index f60aab3e3500..d1a6e6aec837 100644 --- a/SETUP.md +++ b/SETUP.md @@ -1,3 +1,7 @@ + These instructions will help you to set up your development environment, get the source code of the Nextcloud for Android app and build it by yourself. If you want to help developing the app take a look to the [contribution guidelines][0]. Sections 1) and 2) are common for any environment. The rest of the sections describe how to set up a project in different tool environments. Nowadays we recommend to use Android Studio (section 2), but you can also build the app from the command line (section 3). @@ -32,7 +36,7 @@ The next steps will assume you have a GitHub account and that you will get the c * In a web browser, go to https://github.com/nextcloud/android, and click the 'Fork' button near the top right corner. * Open a terminal and go on with the next steps in it. -* Clone your forked repository: ```git clone --recursive https://github.com/YOURGITHUBNAME/android.git```. +* Clone your forked repository: ```git clone https://github.com/YOURGITHUBNAME/android.git```. * Move to the project folder with ```cd android```. * Pull any changes from your remote branch 'master': ```git pull origin master``` * Make official Nextcloud repo known as upstream: ```git remote add upstream https://github.com/nextcloud/android.git``` @@ -49,9 +53,9 @@ We recommend to use the last version available in the stable channel of Android To set up the project in Android Studio follow the next steps: -* Make sure you have called ```git submodule update``` whenever you switched branches * Open Android Studio and select 'Import Project (Eclipse ADT, Gradle, etc)'. Browse through your file system to the folder 'android' where the project is located. Android Studio will then create the '.iml' files it needs. If you ever close the project but the files are still there, you just select 'Open Project…'. The file chooser will show an Android face as the folder icon, which you can select to reopen the project. * Android Studio will try to build the project directly after importing it. To build it manually, follow the menu path 'Build'/'Make Project', or just click the 'Play' button in the toolbar to build and run it in a mobile device or an emulator. The resulting APK file will be saved in the 'build/outputs/apk/' subdirectory in the project folder. +* Setup Android Studio editor configuration for the project: ```Settings``` → ```Editor``` → ```Code Style``` → ```Scheme: Project``` and ```Enable EditorConfig support``` ### 3. Working in a terminal with Gradle: @@ -59,7 +63,6 @@ To set up the project in Android Studio follow the next steps: [Gradle][7] is the build system used by Android Studio to manage the building operations on Android apps. You do not need to install Gradle in your system, and Google recommends not to do it, but instead trusting on the [Gradle wrapper][8] included in the project. * Open a terminal and go to the 'android' directory that contains the repository. -* Make sure you have called ```git submodule update``` whenever you switched branches * Run the 'clean' and 'build' tasks using the Gradle wrapper provided - Windows: ```gradlew.bat clean build``` - Mac OS/Linux: ```./gradlew clean build``` @@ -70,13 +73,10 @@ The generated APK file is saved in android/build/outputs/apk as android-debug.ap ### 4. App flavours -The app is currently equipped to be built with two flavours: -* generic - the regular build, released as a Nextcloud Android app on the Play store -* custom - a customized build, to be used by people who need features we can't or - won't include into the traditional build (like Firebase Analytics) - -When building the *generic*, you will *not* get the dependencies imposed by the *custom* -build. +The app is currently equipped to be built with three flavours: +* generic - the regular build, released as Nextcloud Android app on FDroid +* gplay - with Google Stuff (Push notification), used for Google Play Store +* versionDev - based on master and library master, available as direct download and FDroid [0]: https://github.com/nextcloud/android/blob/master/CONTRIBUTING.md [1]: https://git-scm.com/ @@ -87,3 +87,30 @@ build. [6]: https://developer.android.com/sdk/installing/index.html?pkg=studio [7]: https://gradle.org/ [8]: https://docs.gradle.org/current/userguide/gradle_wrapper.html + +### 5. How-To + +#### 1. Direct usage of library project + +This is handy if one wants to make changes both to files app and library: + +- Add the following to `settings.gradle`: + ```groovy + includeBuild('[path to library clone]') { + dependencySubstitution { + substitute module('com.github.nextcloud:android-library') using project(':library') + } + } + ``` +- Sync project with gradle files + +Now every change in library can be directly used in files app. + +### 6. Troubleshooting + +#### 1. Compilation fails with "java.lang.OutOfMemoryError: Java heap space" error +The default settings for Gradle is to limit the compilation to 1GB of heap. +You can increase that value by : +- adding `org.gradle.jvmargs=-Xmx4G` to `gradle.properties` +- running gradlew(.bat) with this command line : `GRADLE_OPTS="-Xmx4G" ./gradlew clean build" + diff --git a/THIRD_PARTY.txt b/THIRD_PARTY.txt deleted file mode 100644 index 01bfc8ae1239..000000000000 --- a/THIRD_PARTY.txt +++ /dev/null @@ -1,68 +0,0 @@ -################################################################### - Nextcloud Android client - - Copyright (C) 2016 Nextcloud Project - Copyright (C) 2012-2016 ownCloud GmbH - Copyright (C) 2012 Bartek Przybylski -################################################################### - - -########### -# LICENSE # -########### - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License versions 2, -as published by the Free Software Foundation. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -The source distribution of this program should include a full copy -of the GNU GPL version 2 license in the LICENSE.txt file located -in its root directory. If not, see . - - -######################## -# THIRD PARTY LICENSES # -######################## - -Both the source and binary distributions of this software contain -some third party software. All the third party software included -or linked is redistributed under the terms and conditions of their -original licenses. These licenses are compatible the GPL license -that govern this software, for the purposes they are being used. - -The third party software included and used by this project is: - - * Apache JackRabbit 2.12.4. (included by the android-library project) - Copyright (C) 2004-2016 The Apache Software Foundation. - Licensed under Apache License, Version 2.0. - The library is included in the Nextcloud client APK. - See http://jackrabbit.apache.org/ - - * TouchImageView, 1.2.0. commit 6dbeac4f11936185ba374c73144ac431c23c9aab - Copyright (c) 2014 Michael Ortiz - Licensed under MIT License - This library is in the source code. - See https://github.com/MikeOrtiz/TouchImageView - - * floatingactionbutton 1.10.21. - Copyright (c) 2014 Jerzy Chalupski - Licensed under Apache License, Version 2.0. - The library is included in the Nextcloud client APK. - See https://github.com/futuresimple/android-floating-action-button - - * AndroidSVG 1.2.1. - Copyright (c) 2014 Paul LeBeau - Licensed under Apache License, Version 2.0. - The library is included in the Nextcloud client APK. - See https://github.com/BigBadaboom/androidsvg - - * Disk LRU Cache 2.0.2. - Copyright (c) 2013 Jake Wharton - Licensed under Apache License, Version 2.0. - The library is be included in the Nextcloud client APK. - See https://github.com/JakeWharton/DiskLruCache diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000000..e666d0bcd5c1 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000000..3bfd4c229c72 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,516 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Jimly Asshiddiqy + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +@file:Suppress("UnstableApiUsage", "DEPRECATION") + +import com.android.build.gradle.internal.api.ApkVariantOutputImpl +import com.github.spotbugs.snom.Confidence +import com.github.spotbugs.snom.Effort +import com.github.spotbugs.snom.SpotBugsTask +import com.karumi.shot.ShotExtension +import org.gradle.internal.jvm.Jvm +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.io.FileInputStream +import java.util.Properties + +val shotTest = System.getenv("SHOT_TEST") == "true" +val ciBuild = System.getenv("CI") == "true" +val perfAnalysis = project.hasProperty("perfAnalysis") + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.spotless) + alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.spotbugs) + alias(libs.plugins.detekt) + // needed to make renovate run without shot, as shot requires Android SDK + // https://github.com/pedrovgs/Shot/issues/300 + if (System.getenv("SHOT_TEST") == "true") alias(libs.plugins.shot) + id("checkstyle") + id("pmd") +} +apply(from = "${rootProject.projectDir}/jacoco.gradle.kts") + +println("Gradle uses Java ${Jvm.current()}") + +configurations.configureEach { + // via prism4j, already using annotations explicitly + exclude(group = "org.jetbrains", module = "annotations-java5") + + resolutionStrategy { + force(libs.objenesis) + + eachDependency { + if (requested.group == "org.checkerframework" && requested.name != "checker-compat-qual") { + useVersion(libs.versions.checker.get()) + because("https://github.com/google/ExoPlayer/issues/10007") + } else if (requested.group == "org.jacoco") { + useVersion(libs.versions.jacoco.get()) + } else if (requested.group == "commons-logging" && requested.name == "commons-logging") { + useTarget(libs.slfj) + } + } + } +} + +// semantic versioning for version code +val versionMajor = 33 +val versionMinor = 2 +val versionPatch = 0 +val versionBuild = 0 // 0-50=Alpha / 51-98=RC / 90-99=stable + +val ndkEnv = buildMap { + file("${project.rootDir}/ndk.env").readLines().forEach { + val (key, value) = it.split("=") + put(key, value) + } +} + +val configProps = Properties().apply { + val file = rootProject.file("gradle.properties") + if (file.exists()) load(FileInputStream(file)) +} + +val ncTestServerUsername = configProps["NC_TEST_SERVER_USERNAME"] +val ncTestServerPassword = configProps["NC_TEST_SERVER_PASSWORD"] +val ncTestServerBaseUrl = configProps["NC_TEST_SERVER_BASEURL"] + +android { + // install this NDK version and Cmake to produce smaller APKs. Build will still work if not installed + ndkVersion = "${ndkEnv["NDK_VERSION"]}" + + namespace = "com.owncloud.android" + testNamespace = "${namespace}.test" + + androidResources.generateLocaleConfig = true + + defaultConfig { + testInstrumentationRunnerArguments += mapOf( + "TEST_SERVER_URL" to ncTestServerBaseUrl.toString(), + "TEST_SERVER_USERNAME" to ncTestServerUsername.toString(), + "TEST_SERVER_PASSWORD" to ncTestServerPassword.toString(), + "disableAnalytics" to "true" + ) + applicationId = "com.nextcloud.client" + minSdk = 28 + targetSdk = 36 + compileSdk = 36 + + buildConfigField("boolean", "CI", ciBuild.toString()) + buildConfigField("boolean", "RUNTIME_PERF_ANALYSIS", perfAnalysis.toString()) + + // arguments to be passed to functional tests + testInstrumentationRunner = if (shotTest) "com.karumi.shot.ShotTestRunner" + else "com.nextcloud.client.TestRunner" + + versionCode = versionMajor * 10000000 + versionMinor * 10000 + versionPatch * 100 + versionBuild + versionName = when { + versionBuild > 89 -> "${versionMajor}.${versionMinor}.${versionPatch}" + versionBuild > 50 -> "${versionMajor}.${versionMinor}.${versionPatch} RC" + (versionBuild - 50) + else -> "${versionMajor}.${versionMinor}.${versionPatch} Alpha" + (versionBuild + 1) + } + + // adapt structure from Eclipse to Gradle/Android Studio expectations; + // see http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Configuring-the-Structure + + flavorDimensions += "default" + + buildTypes { + release { + buildConfigField("String", "NC_TEST_SERVER_DATA_STRING", "\"\"") + } + + debug { + enableUnitTestCoverage = project.hasProperty("coverage") + enableAndroidTestCoverage = project.hasProperty("coverage") + resConfigs("xxxhdpi") + } + } + + productFlavors { + // used for f-droid + register("generic") { + applicationId = "com.nextcloud.client" + dimension = "default" + } + + register("gplay") { + applicationId = "com.nextcloud.client" + dimension = "default" + } + + register("huawei") { + applicationId = "com.nextcloud.client" + dimension = "default" + } + + register("versionDev") { + applicationId = "com.nextcloud.android.beta" + dimension = "default" + versionCode = 20220322 + versionName = "20220322" + } + + register("qa") { + applicationId = "com.nextcloud.android.qa" + dimension = "default" + versionCode = 1 + versionName = "1" + } + } + } + + applicationVariants.configureEach { + outputs.configureEach { + if (this is ApkVariantOutputImpl) this.outputFileName = "${this.baseName}-${this.versionCode}.apk" + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + animationsDisabled = true + } + + // adapt structure from Eclipse to Gradle/Android Studio expectations; + // see http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Configuring-the-Structure + packaging.resources { + excludes.addAll(listOf("META-INF/LICENSE*", "META-INF/versions/9/OSGI-INF/MANIFEST*")) + pickFirsts.add("MANIFEST.MF") // workaround for duplicated manifest on some dependencies + } + + buildFeatures { + buildConfig = true + dataBinding = true + viewBinding = true + aidl = true + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + lint { + abortOnError = true + warningsAsErrors = true + checkGeneratedSources = true + disable.addAll( + listOf( + "MissingTranslation", + "GradleDependency", + "VectorPath", + "IconMissingDensityFolder", + "IconDensities", + "GoogleAppIndexingWarning", + "MissingDefaultResource", + "InvalidPeriodicWorkRequestInterval", + "StringFormatInvalid", + "MissingQuantity", + "IconXmlAndPng", + "SelectedPhotoAccess", + "UnsafeIntentLaunch" + ) + ) + htmlOutput = layout.buildDirectory.file("reports/lint/lint.html").get().asFile + htmlReport = true + } + + sourceSets { + // Adds exported schema location as test app assets. + getByName("androidTest") { + assets.srcDirs(files("$projectDir/schemas")) + } + } + +} + +ksp.arg("room.schemaLocation", "$projectDir/schemas") + +// Configure KSP for test variants +ksp.arg("dagger.moduleName", project.name) + +kotlin.compilerOptions.jvmTarget.set(JvmTarget.JVM_21) + +spotless.kotlin { + target("**/*.kt") + ktlint() +} + +detekt.config.setFrom("detekt.yml") + +if (shotTest) configure { + showOnlyFailingTestsInReports = ciBuild + // CI environment renders some shadows slightly different from local VMs + // Add a 0.5% tolerance to account for that + tolerance = if (ciBuild) 0.1 else 0.0 +} + + +spotbugs { + ignoreFailures = true // should continue checking + effort = Effort.MAX + reportLevel = Confidence.valueOf("MEDIUM") +} + +tasks.register("checkstyle") { + configFile = file("${rootProject.projectDir}/checkstyle.xml") + setConfigProperties( + "checkstyleSuppressionsPath" to file("${rootProject.rootDir}/suppressions.xml").absolutePath + ) + source("src") + include("**/*.java") + exclude("**/gen/**") + classpath = files() +} + +tasks.register("pmd") { + ruleSetFiles = files("${rootProject.rootDir}/ruleset.xml") + ignoreFailures = true // should continue checking + ruleSets = emptyList() + + source("src") + include("**/*.java") + exclude("**/gen/**") + + reports { + xml.outputLocation.set(layout.buildDirectory.file("reports/pmd/pmd.xml").get().asFile) + html.outputLocation.set(layout.buildDirectory.file("reports/pmd/pmd.html").get().asFile) + } +} + +tasks.withType().configureEach { + val variantNameCap = name.replace("spotbugs", "") + val variantName = variantNameCap.substring(0, 1).lowercase() + variantNameCap.substring(1) + dependsOn("compile${variantNameCap}Sources") + + classes = fileTree( + layout.buildDirectory.get().asFile.toString() + + "/intermediates/javac/${variantName}/compile${variantNameCap}JavaWithJavac/classes/" + ) + excludeFilter.set(file("${project.rootDir}/scripts/analysis/spotbugs-filter.xml")) + + reports.create("xml") { + required.set(true) + } + reports.create("html") { + required.set(true) + outputLocation.set(layout.buildDirectory.file("reports/spotbugs/spotbugs.html")) + setStylesheet("fancy.xsl") + } +} + +// Run the compiler as a separate process +tasks.withType().configureEach { + options.isFork = true + + // Enable Incremental Compilation + options.isIncremental = true +} + +tasks.withType().configureEach { + // Run tests in parallel + maxParallelForks = maxOf(1, Runtime.getRuntime().availableProcessors().div(2)) + + // increased logging for tests + testLogging.events("passed", "skipped", "failed") +} + +tasks.named("check").configure { + dependsOn("checkstyle", "spotbugsGplayDebug", "pmd", "lint", "spotlessKotlinCheck", "detekt") +} + +dependencies { + // region Nextcloud library + implementation(libs.android.library) { + exclude(group = "org.ogce", module = "xpp3") // unused in Android and brings wrong Junit version + } + // endregion + + // region Splash Screen + implementation(libs.splashscreen) + // endregion + + // region Jetpack Compose + implementation(platform(libs.compose.bom)) + implementation(libs.material.icons.core) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.material3) + implementation(libs.compose.activity) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.foundation) + debugImplementation(libs.compose.ui.tooling) + // endregion + + // region Media3 + implementation(libs.bundles.media3) + // endregion + + // region Room + implementation(libs.room.runtime) + ksp(libs.room.compiler) + androidTestImplementation(libs.room.testing) + // endregion + + // region Espresso + androidTestImplementation(libs.bundles.espresso) + // endregion + + // region Glide + implementation(libs.glide) + ksp(libs.ksp) + // endregion + + // region UI + implementation(libs.bundles.ui) + // endregion + + // region Worker + implementation(libs.work.runtime) + implementation(libs.work.runtime.ktx) + // endregion + + // region Lifecycle + implementation(libs.lifecycle.viewmodel.ktx) + implementation(libs.lifecycle.service) + implementation(libs.lifecycle.runtime.ktx) + // endregion + + // region JUnit + androidTestImplementation(libs.junit) + androidTestImplementation(libs.rules) + androidTestImplementation(libs.runner) + androidTestUtil(libs.orchestrator) + androidTestImplementation(libs.core.ktx) + androidTestImplementation(libs.core.testing) + // endregion + + // region other libraries + compileOnly(libs.org.jbundle.util.osgi.wrapped.org.apache.http.client) + implementation(libs.commons.httpclient.commons.httpclient) // remove after entire switch to lib v2 + implementation(libs.jackrabbit.webdav) // remove after entire switch to lib v2 + implementation(libs.constraintlayout) + implementation(libs.legacy.support.v4) + implementation(libs.material) + implementation(libs.disklrucache) + implementation(libs.juniversalchardet) // need this version for Android <7 + compileOnly(libs.annotations) + implementation(libs.commons.io) + implementation(libs.eventbus) + implementation(libs.ez.vcard) + implementation(libs.nnio) + implementation(libs.bcpkix.jdk18on) + implementation(libs.gson) + implementation(libs.sectioned.recyclerview) + implementation(libs.photoview) + implementation(libs.android.gif.drawable) + implementation(libs.qrcodescanner) // "com.github.blikoon:QRCodeScanner:0.1.2" + implementation(libs.flexbox) + implementation(libs.androidsvg) + implementation(libs.annotation) + implementation(libs.emoji.google) + // endregion + + // region AppScan, document scanner not available on FDroid (generic) due to OpenCV binaries + // To enable the feature for another variant, add it here. + "gplayImplementation"(project(":appscan")) + "huaweiImplementation"(project(":appscan")) + "qaImplementation"(project(":appscan")) + // endregion + + // region SpotBugs + spotbugsPlugins(libs.findsecbugs.plugin) + spotbugsPlugins(libs.fb.contrib) + // endregion + + // region Dagger + implementation(libs.dagger) + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + ksp(libs.dagger.compiler) + ksp(libs.dagger.processor) + kspAndroidTest(libs.dagger.compiler) + // endregion + + // region Crypto + implementation(libs.conscrypt.android) + // endregion + + // region Library + implementation(libs.library) + // endregion + + // region Shimmer + implementation(libs.loaderviewlibrary) + // endregion + + // region Markdown rendering + implementation(libs.bundles.markdown.rendering) + // endregion + + // region Image cropping / rotation + implementation(libs.android.image.cropper) + // endregion + + // region Maps + implementation(libs.osmdroid.android) + // endregion + + // region iCal4j + implementation(libs.ical4j) { + listOf("org.apache.commons", "commons-logging").forEach { groupName -> exclude(group = groupName) } + } + // endregion + + // region LeakCanary + if (perfAnalysis) debugImplementation(libs.leakcanary) + // endregion + + // region Local Unit Test + testImplementation(libs.bundles.unit.test) + // endregion + + // region Mocking support + androidTestImplementation(libs.bundles.mocking) + // endregion + + // region UIAutomator + // UIAutomator - for cross-app UI tests, and to grant screen is turned on in Espresso tests + // androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0" + // fix conflict in dependencies; see http://g.co/androidstudio/app-test-app-conflict for details + // androidTestImplementation("com.android.support:support-annotations:${supportLibraryVersion}" + androidTestImplementation(libs.screengrab) + // endregion + + // region Kotlin + implementation(libs.kotlin.stdlib) + // endregion + + // region Stateless + implementation(libs.stateless4j) + // endregion + + // region Google Play dependencies, upon each update first test: new registration, receive push + "gplayImplementation"(libs.bundles.gplay) + // endregion + + // region common + implementation(libs.ui) + implementation(libs.common.core) + // endregion + + // region Image loading + implementation(libs.coil) + // endregion + + // kotlinx.serialization + implementation(libs.kotlinx.serialization.json) +} diff --git a/app/detekt.yml b/app/detekt.yml new file mode 100644 index 000000000000..91abf2b4b70c --- /dev/null +++ b/app/detekt.yml @@ -0,0 +1,414 @@ +# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +build: + maxIssues: 2 + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +processors: + active: true + exclude: + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ClassCountProcessor' + # - 'PackageCountProcessor' + # - 'KtFileCountProcessor' + +console-reports: + active: true + exclude: + # - 'ProjectStatisticsReport' + # - 'ComplexityReport' + # - 'NotificationReport' + # - 'FindingsReport' + # - 'BuildFailureReport' + +comments: + active: true + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!]$) + UndocumentedPublicClass: + active: false + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + ComplexMethod: + active: true + threshold: 10 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + excludes: ['**/androidTest/**'] + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + excludes: ['**/androidTest/**'] + LongParameterList: + active: true + functionThreshold: 7 + constructorThreshold: 6 + ignoreDefaultParameters: false + MethodOverloading: + active: false + threshold: 6 + NestedBlockDepth: + active: true + threshold: 4 + StringLiteralDuplication: + active: false + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + thresholdInFiles: 15 + thresholdInClasses: 15 + thresholdInInterfaces: 15 + thresholdInObjects: 15 + thresholdInEnums: 11 + ignoreDeprecated: true + ignorePrivate: false + ignoreOverridden: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + methodNames: [toString,hashCode,equals,finalize] + InstanceOfCheckForException: + active: false + NotImplementedDeclaration: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: false + ReturnFromFinally: + active: false + SwallowedException: + active: false + ignoredExceptionTypes: [InterruptedException,NumberFormatException,ParseException,MalformedURLException] + ThrowingExceptionFromFinally: + active: false + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + exceptions: [IllegalArgumentException,IllegalStateException,IOException] + ThrowingNewInstanceOfSameException: + active: false + TooGenericExceptionCaught: + active: true + exceptionNames: + - ArrayIndexOutOfBoundsException + - Error + - Exception + - IllegalMonitorStateException + - NullPointerException + - IndexOutOfBoundsException + - RuntimeException + - Throwable + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + TooGenericExceptionThrown: + active: true + exceptionNames: + - Error + - Exception + - Throwable + - RuntimeException + +naming: + active: true + ClassNaming: + active: true + classPattern: '[A-Z$][a-zA-Z0-9$]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + functionPattern: '^([a-z$A-Z][a-zA-Z$0-9]*)|(`.*`)$' + excludeClassPattern: '$^' + ignoreOverridden: true + excludes: + - "**/*Test.kt" + - "**/*IT.kt" + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + MatchingDeclarationName: + active: true + MemberNameEqualsClassName: + active: false + ignoreOverridden: true + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + +performance: + active: true + ArrayPrimitive: + active: false + ForEachOnRange: + active: true + SpreadOperator: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: false + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + InvalidRange: + active: false + IteratorHasNextCallsNextMethod: + active: false + IteratorNotThrowingNoSuchElementException: + active: false + LateinitUsage: + active: false + ignoreAnnotated: [] + ignoreOnClassesPattern: "" + UnconditionalJumpStatementInLoop: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: false + UnsafeCast: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: false + +style: + active: true + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: [to] + EqualsNullCall: + active: false + EqualsOnSignatureLine: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenComment: + active: true + values: 'TODO:,FIXME:,STOPSHIP:' + ForbiddenImport: + active: false + imports: [] + ForbiddenVoid: + active: false + FunctionOnlyReturningConstant: + active: false + ignoreOverridableFunction: true + excludedFunctions: [describeContents] + LoopWithTooManyJumpStatements: + active: false + maxJumpCount: 1 + MagicNumber: + active: true + ignoreNumbers: ["-1","0","1","2"] + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + excludes: + - "**/*Test.kt" + - "**/*IT.kt" + MandatoryBracesIfStatements: + active: false + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + MayBeConst: + active: false + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: false + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: [equals] + excludeLabeled: false + excludeReturnFromLambda: true + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 2 + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableLength: 5 + UnnecessaryAbstractClass: + active: false + ignoreAnnotated: + - "dagger.Module" + UnnecessaryApply: + active: false + UnnecessaryInheritance: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedPrivateClass: + active: false + UnusedPrivateMember: + active: false + allowedNames: "(_|ignored|expected|serialVersionUID)" + UseDataClass: + active: false + ignoreAnnotated: [] + UtilityClassWithPublicConstructor: + active: false + VarCouldBeVal: + active: false + WildcardImport: + active: true + excludeImports: [java.util.*,kotlinx.android.synthetic.*] diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 000000000000..2316b422a838 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/100.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/100.json new file mode 100644 index 000000000000..8d0c6556b2f3 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/100.json @@ -0,0 +1,1308 @@ +{ + "formatVersion": 1, + "database": { + "version": 100, + "identityHash": "0fa307b3a70cccc7f1486f39b59d6e2b", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` TEXT, `forbidden_filenames` TEXT, `forbidden_filename_extensions` TEXT, `forbidden_filename_basenames` TEXT, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER, `client_integration_json` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "TEXT" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientIntegrationJson", + "columnName": "client_integration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `upload_end_timestamp_long` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestampLong", + "columnName": "upload_end_timestamp_long", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "assistant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT, `type` TEXT, `status` TEXT, `userId` TEXT, `appId` TEXT, `input` TEXT, `output` TEXT, `completionExpectedAt` INTEGER, `progress` INTEGER, `lastUpdated` INTEGER, `scheduledAt` INTEGER, `endedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT" + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "TEXT" + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "completionExpectedAt", + "columnName": "completionExpectedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '0fa307b3a70cccc7f1486f39b59d6e2b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/65.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/65.json new file mode 100644 index 000000000000..cba9c61eda96 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/65.json @@ -0,0 +1,1118 @@ +{ + "formatVersion": 1, + "database": { + "version": 65, + "identityHash": "1aa68e80a3cb0006ef54981abad692a9", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1aa68e80a3cb0006ef54981abad692a9')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/66.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/66.json new file mode 100644 index 000000000000..e001fe37169f --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/66.json @@ -0,0 +1,1124 @@ +{ + "formatVersion": 1, + "database": { + "version": 66, + "identityHash": "97be4a2bf1d8d2a4db027a996a823010", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '97be4a2bf1d8d2a4db027a996a823010')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/67.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/67.json new file mode 100644 index 000000000000..15752cfeb4be --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/67.json @@ -0,0 +1,1130 @@ +{ + "formatVersion": 1, + "database": { + "version": 67, + "identityHash": "be969fd72e75dd6116b4fa746bcf5b6a", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be969fd72e75dd6116b4fa746bcf5b6a')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/68.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/68.json new file mode 100644 index 000000000000..7fb15fcf6d81 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/68.json @@ -0,0 +1,1131 @@ +{ + "formatVersion": 1, + "database": { + "version": 68, + "identityHash": "aae2b31e197f961249b1ecd2cb13cbd1", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aae2b31e197f961249b1ecd2cb13cbd1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/69.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/69.json new file mode 100644 index 000000000000..6f7912ee4857 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/69.json @@ -0,0 +1,1137 @@ +{ + "formatVersion": 1, + "database": { + "version": 69, + "identityHash": "4f593cdd41a85be7b67c756cf2848028", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '4f593cdd41a85be7b67c756cf2848028')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/70.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/70.json new file mode 100644 index 000000000000..4f37cb7e7db8 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/70.json @@ -0,0 +1,1143 @@ +{ + "formatVersion": 1, + "database": { + "version": 70, + "identityHash": "94c41622b9c906e5e15633eaf4943d1d", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '94c41622b9c906e5e15633eaf4943d1d')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/71.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/71.json new file mode 100644 index 000000000000..e9a4ed24fe40 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/71.json @@ -0,0 +1,1143 @@ +{ + "formatVersion": 1, + "database": { + "version": 71, + "identityHash": "94c41622b9c906e5e15633eaf4943d1d", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '94c41622b9c906e5e15633eaf4943d1d')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/72.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/72.json new file mode 100644 index 000000000000..42beb5a2e6a6 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/72.json @@ -0,0 +1,1149 @@ +{ + "formatVersion": 1, + "database": { + "version": 72, + "identityHash": "588228aa504f37ac818ca4a664af5b3d", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '588228aa504f37ac818ca4a664af5b3d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/73.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/73.json new file mode 100644 index 000000000000..0edac3d8e93f --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/73.json @@ -0,0 +1,1155 @@ +{ + "formatVersion": 1, + "database": { + "version": 73, + "identityHash": "ec3cffde64bdc99ac2b308e8ba15c481", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, 'ec3cffde64bdc99ac2b308e8ba15c481')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/74.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/74.json new file mode 100644 index 000000000000..d37b3654d96b --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/74.json @@ -0,0 +1,1161 @@ +{ + "formatVersion": 1, + "database": { + "version": 74, + "identityHash": "7e73c045ac6d52d6c7c1626eefbc21e9", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '7e73c045ac6d52d6c7c1626eefbc21e9')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/75.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/75.json new file mode 100644 index 000000000000..2a7d08125b71 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/75.json @@ -0,0 +1,1173 @@ +{ + "formatVersion": 1, + "database": { + "version": 75, + "identityHash": "7558389fb83b310bead5d07c9a0bc722", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '7558389fb83b310bead5d07c9a0bc722')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/76.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/76.json new file mode 100644 index 000000000000..b907f637f9a8 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/76.json @@ -0,0 +1,1179 @@ +{ + "formatVersion": 1, + "database": { + "version": 76, + "identityHash": "0d639ab041aa87e6c2ef9504395545f7", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '0d639ab041aa87e6c2ef9504395545f7')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/77.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/77.json new file mode 100644 index 000000000000..8031ac6b6431 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/77.json @@ -0,0 +1,1191 @@ +{ + "formatVersion": 1, + "database": { + "version": 77, + "identityHash": "a3c1d02f306c6613a9a0d392b6cfa7f8", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, 'a3c1d02f306c6613a9a0d392b6cfa7f8')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/78.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/78.json new file mode 100644 index 000000000000..9ac5f2e87f7f --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/78.json @@ -0,0 +1,1197 @@ +{ + "formatVersion": 1, + "database": { + "version": 78, + "identityHash": "f26afed3b9b87a3acb578947a26223ac", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, 'f26afed3b9b87a3acb578947a26223ac')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/79.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/79.json new file mode 100644 index 000000000000..708ca00a730e --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/79.json @@ -0,0 +1,1203 @@ +{ + "formatVersion": 1, + "database": { + "version": 79, + "identityHash": "ec997f271f9045e8483b260f036a168f", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, 'ec997f271f9045e8483b260f036a168f')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/80.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/80.json new file mode 100644 index 000000000000..f0e467cfda6c --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/80.json @@ -0,0 +1,1203 @@ +{ + "formatVersion": 1, + "database": { + "version": 80, + "identityHash": "dda37e2f97cecb10da04723f5c472d00", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, 'dda37e2f97cecb10da04723f5c472d00')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/81.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/81.json new file mode 100644 index 000000000000..3dd2580e23ed --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/81.json @@ -0,0 +1,1209 @@ +{ + "formatVersion": 1, + "database": { + "version": 81, + "identityHash": "082a63031678a67879428f688f02d3b5", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '082a63031678a67879428f688f02d3b5')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/82.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/82.json new file mode 100644 index 000000000000..e16d7c189e2e --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/82.json @@ -0,0 +1,1233 @@ +{ + "formatVersion": 1, + "database": { + "version": 82, + "identityHash": "e78b1402db9da7caff78c46fff585672", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, 'e78b1402db9da7caff78c46fff585672')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/83.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/83.json new file mode 100644 index 000000000000..c27bba8f602e --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/83.json @@ -0,0 +1,1245 @@ +{ + "formatVersion": 1, + "database": { + "version": 83, + "identityHash": "365a8731a100a61ae5029beb74acd02e", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '365a8731a100a61ae5029beb74acd02e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/84.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/84.json new file mode 100644 index 000000000000..b703cbeaa0b1 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/84.json @@ -0,0 +1,1301 @@ +{ + "formatVersion": 1, + "database": { + "version": 84, + "identityHash": "70f2e2adb603afda7f87dbfb3b902e02", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_parent_path` TEXT, `offline_operations_type` TEXT, `offline_operations_path` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentPath", + "columnName": "offline_operations_parent_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '70f2e2adb603afda7f87dbfb3b902e02')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/85.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/85.json new file mode 100644 index 000000000000..5e2a33ba006c --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/85.json @@ -0,0 +1,1301 @@ +{ + "formatVersion": 1, + "database": { + "version": 85, + "identityHash": "2d24b9210a36150f221156d2e8f59665", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '2d24b9210a36150f221156d2e8f59665')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/86.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/86.json new file mode 100644 index 000000000000..2571f61f74b5 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/86.json @@ -0,0 +1,1331 @@ +{ + "formatVersion": 1, + "database": { + "version": 86, + "identityHash": "277489b9d4a6ee84f96d09dea39591ba", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '277489b9d4a6ee84f96d09dea39591ba')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/87.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/87.json new file mode 100644 index 000000000000..2bce6bc1ee01 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/87.json @@ -0,0 +1,1337 @@ +{ + "formatVersion": 1, + "database": { + "version": 87, + "identityHash": "c67369ca15672b4c84289aa188f49e50", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, 'c67369ca15672b4c84289aa188f49e50')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/88.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/88.json new file mode 100644 index 000000000000..925592424561 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/88.json @@ -0,0 +1,1343 @@ +{ + "formatVersion": 1, + "database": { + "version": 88, + "identityHash": "72369823c54307097d8ca60cf6944e2a", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '72369823c54307097d8ca60cf6944e2a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/89.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/89.json new file mode 100644 index 000000000000..e54001fc2fc8 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/89.json @@ -0,0 +1,1349 @@ +{ + "formatVersion": 1, + "database": { + "version": 89, + "identityHash": "7a70f9151914c24eb0e5350316b126f5", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '7a70f9151914c24eb0e5350316b126f5')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/90.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/90.json new file mode 100644 index 000000000000..c0b53e5fd543 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/90.json @@ -0,0 +1,1355 @@ +{ + "formatVersion": 1, + "database": { + "version": 90, + "identityHash": "93eb4d5fbf952984b6fc2df9f7c369e1", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "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, '93eb4d5fbf952984b6fc2df9f7c369e1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/91.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/91.json new file mode 100644 index 000000000000..a338fc43eaaf --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/91.json @@ -0,0 +1,1195 @@ +{ + "formatVersion": 1, + "database": { + "version": 91, + "identityHash": "16f8a78a87d896adf01d545ed83142e5", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + } + ], + "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, '16f8a78a87d896adf01d545ed83142e5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/92.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/92.json new file mode 100644 index 000000000000..092bf97376ff --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/92.json @@ -0,0 +1,1200 @@ +{ + "formatVersion": 1, + "database": { + "version": 92, + "identityHash": "aeef27ff00555d37d8605e760a446863", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + } + ], + "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, 'aeef27ff00555d37d8605e760a446863')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/93.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/93.json new file mode 100644 index 000000000000..c619e1b431f4 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/93.json @@ -0,0 +1,1205 @@ +{ + "formatVersion": 1, + "database": { + "version": 93, + "identityHash": "bbaa274a7bcf9daf381451c8e77d6930", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + } + ], + "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, 'bbaa274a7bcf9daf381451c8e77d6930')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/94.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/94.json new file mode 100644 index 000000000000..203afe31143a --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/94.json @@ -0,0 +1,1210 @@ +{ + "formatVersion": 1, + "database": { + "version": 94, + "identityHash": "84d16467d3052f332b38942987052f00", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + } + ], + "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, '84d16467d3052f332b38942987052f00')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/95.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/95.json new file mode 100644 index 000000000000..694e08c933bc --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/95.json @@ -0,0 +1,1215 @@ +{ + "formatVersion": 1, + "database": { + "version": 95, + "identityHash": "a2a95af369b3e75d48b6c454d1fe6c2d", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + } + ], + "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, 'a2a95af369b3e75d48b6c454d1fe6c2d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/96.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/96.json new file mode 100644 index 000000000000..4dc4849f5551 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/96.json @@ -0,0 +1,1293 @@ +{ + "formatVersion": 1, + "database": { + "version": 96, + "identityHash": "0e9718354266517a340a89e16bb7d373", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "assistant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT, `type` TEXT, `status` TEXT, `userId` TEXT, `appId` TEXT, `input` TEXT, `output` TEXT, `completionExpectedAt` INTEGER, `progress` INTEGER, `lastUpdated` INTEGER, `scheduledAt` INTEGER, `endedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT" + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "TEXT" + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "completionExpectedAt", + "columnName": "completionExpectedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '0e9718354266517a340a89e16bb7d373')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/97.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/97.json new file mode 100644 index 000000000000..27f506701672 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/97.json @@ -0,0 +1,1298 @@ +{ + "formatVersion": 1, + "database": { + "version": 97, + "identityHash": "1c5a77152bf79ee80f9e6eb2677d75a7", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER, `client_integration_json` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientIntegrationJson", + "columnName": "client_integration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "assistant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT, `type` TEXT, `status` TEXT, `userId` TEXT, `appId` TEXT, `input` TEXT, `output` TEXT, `completionExpectedAt` INTEGER, `progress` INTEGER, `lastUpdated` INTEGER, `scheduledAt` INTEGER, `endedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT" + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "TEXT" + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "completionExpectedAt", + "columnName": "completionExpectedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '1c5a77152bf79ee80f9e6eb2677d75a7')" + ] + } +} diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/98.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/98.json new file mode 100644 index 000000000000..ff3d56813529 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/98.json @@ -0,0 +1,1303 @@ +{ + "formatVersion": 1, + "database": { + "version": 98, + "identityHash": "02ca435c31a732cd82a36717518c37b3", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER, `client_integration_json` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientIntegrationJson", + "columnName": "client_integration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `upload_end_timestamp_long` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestampLong", + "columnName": "upload_end_timestamp_long", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "assistant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT, `type` TEXT, `status` TEXT, `userId` TEXT, `appId` TEXT, `input` TEXT, `output` TEXT, `completionExpectedAt` INTEGER, `progress` INTEGER, `lastUpdated` INTEGER, `scheduledAt` INTEGER, `endedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT" + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "TEXT" + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "completionExpectedAt", + "columnName": "completionExpectedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '02ca435c31a732cd82a36717518c37b3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/99.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/99.json new file mode 100644 index 000000000000..e9146d470f08 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/99.json @@ -0,0 +1,1308 @@ +{ + "formatVersion": 1, + "database": { + "version": 99, + "identityHash": "29842d7d75a54c57a2203a5b041346d7", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER, `client_integration_json` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientIntegrationJson", + "columnName": "client_integration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `upload_end_timestamp_long` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestampLong", + "columnName": "upload_end_timestamp_long", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "assistant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT, `type` TEXT, `status` TEXT, `userId` TEXT, `appId` TEXT, `input` TEXT, `output` TEXT, `completionExpectedAt` INTEGER, `progress` INTEGER, `lastUpdated` INTEGER, `scheduledAt` INTEGER, `endedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT" + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "TEXT" + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "completionExpectedAt", + "columnName": "completionExpectedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '29842d7d75a54c57a2203a5b041346d7')" + ] + } +} \ No newline at end of file diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_empty.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_empty.png new file mode 100644 index 000000000000..dd1710e0598c Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_empty.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_empty_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_empty_light_white.png new file mode 100644 index 000000000000..77c579d6b44f Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_empty_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_error.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_error.png new file mode 100644 index 000000000000..196a8e04cc79 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_error.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_error_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_error_light_white.png new file mode 100644 index 000000000000..d6e8ed794aad Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_error_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_loading.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_loading.png new file mode 100644 index 000000000000..a9acbd463afd Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_loading.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer.png new file mode 100644 index 000000000000..833e68d68f6f Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_black.png new file mode 100644 index 000000000000..3076d94293cc Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_blue.png new file mode 100644 index 000000000000..d5c15f37a336 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_white.png new file mode 100644 index 000000000000..218890965fd7 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_light_black.png new file mode 100644 index 000000000000..2acd76c3ab2b Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_light_white.png new file mode 100644 index 000000000000..c3c8cd4f0005 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_openDrawer_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_showActivities.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_showActivities.png new file mode 100644 index 000000000000..d2014c5fd4c4 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_showActivities.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_showActivities_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_showActivities_light_white.png new file mode 100644 index 000000000000..65ac34ec1002 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.ActivitiesFragmentIT_showActivities_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login.png b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login.png new file mode 100644 index 000000000000..a351456e61ac Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_black.png new file mode 100644 index 000000000000..515349cef053 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_blue.png new file mode 100644 index 000000000000..515349cef053 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_white.png new file mode 100644 index 000000000000..515349cef053 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_light_black.png new file mode 100644 index 000000000000..515349cef053 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_light_white.png new file mode 100644 index 000000000000..515349cef053 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.AuthenticatorActivityIT_login_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open.png b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open.png new file mode 100644 index 000000000000..2b44c91846ac Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_black.png new file mode 100644 index 000000000000..290a8c11f25b Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_blue.png new file mode 100644 index 000000000000..a8ae0ac0358d Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_white.png new file mode 100644 index 000000000000..d1a46b919cc4 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_light_black.png new file mode 100644 index 000000000000..21ef8e716ec2 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_light_white.png new file mode 100644 index 000000000000..f71a768f6904 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.CommunityFragmentIT_open_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_shareToCircle.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_shareToCircle.png new file mode 100644 index 000000000000..b2a4b29266b1 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_shareToCircle.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_showAccounts.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_showAccounts.png new file mode 100644 index 000000000000..9652ab3c9f6e Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_showAccounts.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_showShares.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_showShares.png new file mode 100644 index 000000000000..9e218692fad9 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityIT_showShares.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer.png new file mode 100644 index 000000000000..5c12e09cc323 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_black.png new file mode 100644 index 000000000000..5cd78a4b2d07 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_blue.png new file mode 100644 index 000000000000..038077ce095e Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_white.png new file mode 100644 index 000000000000..170e169bc22c Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_light_black.png new file mode 100644 index 000000000000..ee092b471201 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_light_white.png new file mode 100644 index 000000000000..ff64ce576f98 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_drawer_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open.png new file mode 100644 index 000000000000..a2cae833232d Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_black.png new file mode 100644 index 000000000000..aafd6c283e5b Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_blue.png new file mode 100644 index 000000000000..aafd6c283e5b Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_white.png new file mode 100644 index 000000000000..f730359e3d88 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_light_black.png new file mode 100644 index 000000000000..c66e6f381610 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_light_white.png new file mode 100644 index 000000000000..f3ad46459c2d Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_open_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_showMediaThenAllFiles.png b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_showMediaThenAllFiles.png new file mode 100644 index 000000000000..c3ad35c44b8d Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FileDisplayActivityScreenshotIT_showMediaThenAllFiles.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open.png b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open.png new file mode 100644 index 000000000000..bb9d8107c2a2 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_black.png new file mode 100644 index 000000000000..ac2e60fa9eb1 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_blue.png new file mode 100644 index 000000000000..ac2e60fa9eb1 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_white.png new file mode 100644 index 000000000000..ac2e60fa9eb1 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_light_black.png new file mode 100644 index 000000000000..ac2e60fa9eb1 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_light_white.png new file mode 100644 index 000000000000..ac2e60fa9eb1 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.FirstRunActivityIT_open_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open.png new file mode 100644 index 000000000000..9015c835632b Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_black.png new file mode 100644 index 000000000000..c7f41ff49511 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_blue.png new file mode 100644 index 000000000000..a096dafecb78 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_white.png new file mode 100644 index 000000000000..c7f41ff49511 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_light_black.png new file mode 100644 index 000000000000..6da4a72470b1 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_light_white.png new file mode 100644 index 000000000000..45d083fa037d Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_open_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error.png new file mode 100644 index 000000000000..8a19c85feb54 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_black.png new file mode 100644 index 000000000000..2b2b883ef224 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_blue.png new file mode 100644 index 000000000000..5f6b5864aacd Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_white.png new file mode 100644 index 000000000000..2b2b883ef224 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_light_black.png new file mode 100644 index 000000000000..fab5fbc443de Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_light_white.png new file mode 100644 index 000000000000..96effe52cacf Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SettingsActivityIT_showMnemonic_Error_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open.png new file mode 100644 index 000000000000..ec519bc24003 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer.png new file mode 100644 index 000000000000..f0934cfadd82 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_dark_blue.png new file mode 100644 index 000000000000..495e6d396284 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_dark_white.png new file mode 100644 index 000000000000..2ef4149e4024 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_light_white.png new file mode 100644 index 000000000000..28f34bc03e6b Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_openDrawer_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_black.png new file mode 100644 index 000000000000..2abfddc00424 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_blue.png new file mode 100644 index 000000000000..2abfddc00424 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_white.png new file mode 100644 index 000000000000..2abfddc00424 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_light_black.png new file mode 100644 index 000000000000..55399677cbcb Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_light_white.png new file mode 100644 index 000000000000..55399677cbcb Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_open_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_showPowerCheckDialog.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_showPowerCheckDialog.png new file mode 100644 index 000000000000..c99e6641857d Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_showPowerCheckDialog.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog.png new file mode 100644 index 000000000000..99e5eb969be4 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_black.png new file mode 100644 index 000000000000..022759484724 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_blue.png new file mode 100644 index 000000000000..7c69afc73dd6 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_white.png new file mode 100644 index 000000000000..022759484724 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_light_black.png new file mode 100644 index 000000000000..ecbca7c15182 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_light_white.png new file mode 100644 index 000000000000..bb43b1bccb90 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.SyncedFoldersActivityIT_testSyncedFolderDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer.png new file mode 100644 index 000000000000..0d8d7edc40a1 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_black.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_black.png new file mode 100644 index 000000000000..5b2ff3d6debc Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_blue.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_blue.png new file mode 100644 index 000000000000..467ece0ee301 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_white.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_white.png new file mode 100644 index 000000000000..f08c5db01860 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_black.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_black.png new file mode 100644 index 000000000000..a65362bc150b Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_black.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_white.png b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_white.png new file mode 100644 index 000000000000..96e8a00fce37 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.UploadListActivityActivityIT_openDrawer_light_white.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.etm.EtmActivityTest_accounts.png b/app/screenshots/generic/debug/com.nextcloud.client.etm.EtmActivityTest_accounts.png new file mode 100644 index 000000000000..555ae8fb8104 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.etm.EtmActivityTest_accounts.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.client.etm.EtmActivityTest_overview.png b/app/screenshots/generic/debug/com.nextcloud.client.etm.EtmActivityTest_overview.png new file mode 100644 index 000000000000..774de814bc05 Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.client.etm.EtmActivityTest_overview.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.ui.BitmapIT_glideSVG.png b/app/screenshots/generic/debug/com.nextcloud.ui.BitmapIT_glideSVG.png new file mode 100644 index 000000000000..668c4cd0e8da Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.ui.BitmapIT_glideSVG.png differ diff --git a/app/screenshots/generic/debug/com.nextcloud.ui.BitmapIT_roundBitmap.png b/app/screenshots/generic/debug/com.nextcloud.ui.BitmapIT_roundBitmap.png new file mode 100644 index 000000000000..0a5549bba0ff Binary files /dev/null and b/app/screenshots/generic/debug/com.nextcloud.ui.BitmapIT_roundBitmap.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth.png new file mode 100644 index 000000000000..e27697112456 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_black.png new file mode 100644 index 000000000000..0d3cc8251e19 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_blue.png new file mode 100644 index 000000000000..02f65be2ba82 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_white.png new file mode 100644 index 000000000000..49af02d39f34 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_light_black.png new file mode 100644 index 000000000000..36b176be1255 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_light_white.png new file mode 100644 index 000000000000..123a2bbe15e5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepBoth_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting.png new file mode 100644 index 000000000000..b03c71c99048 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_black.png new file mode 100644 index 000000000000..45f089989f80 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_blue.png new file mode 100644 index 000000000000..68de8cefbfd6 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_white.png new file mode 100644 index 000000000000..b1ef98d892a0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_light_black.png new file mode 100644 index 000000000000..3c6e2e5f8e3b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_light_white.png new file mode 100644 index 000000000000..cef18345bfcb Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepExisting_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew.png new file mode 100644 index 000000000000..08bfbf1e8e0c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_black.png new file mode 100644 index 000000000000..54918671bc2f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_blue.png new file mode 100644 index 000000000000..595f7d7e305f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_white.png new file mode 100644 index 000000000000..48fb018c99cd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_light_black.png new file mode 100644 index 000000000000..d9c9f27de941 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_light_white.png new file mode 100644 index 000000000000..38af7a8fea3d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_keepNew_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles.png new file mode 100644 index 000000000000..e5b402d02f6c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_black.png new file mode 100644 index 000000000000..27fe02f40468 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_blue.png new file mode 100644 index 000000000000..d550431458b9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_white.png new file mode 100644 index 000000000000..e528ebbc7ab6 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_light_black.png new file mode 100644 index 000000000000..21c9d22c3019 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_light_white.png new file mode 100644 index 000000000000..ea036afb236d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ConflictsResolveActivityIT_screenshotTextFiles_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference.png new file mode 100644 index 000000000000..a3e76e7f4b64 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_black.png new file mode 100644 index 000000000000..c33972eea122 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_blue.png new file mode 100644 index 000000000000..da04a1348389 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_white.png new file mode 100644 index 000000000000..3174fd6f486e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_light_black.png new file mode 100644 index 000000000000..1f384ef69495 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_light_white.png new file mode 100644 index 000000000000..a42bdeea85fc Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openContactsPreference_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF.png new file mode 100644 index 000000000000..bd17b40c6ee8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_black.png new file mode 100644 index 000000000000..83a2d783f0df Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_blue.png new file mode 100644 index 000000000000..83a2d783f0df Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_white.png new file mode 100644 index 000000000000..83a2d783f0df Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_light_black.png new file mode 100644 index 000000000000..62734527a228 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_light_white.png new file mode 100644 index 000000000000..62734527a228 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ContactsPreferenceActivityIT_openVCF_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open.png new file mode 100644 index 000000000000..86f63418fba1 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_black.png new file mode 100644 index 000000000000..b573343b1dc2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_blue.png new file mode 100644 index 000000000000..91f942609851 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_white.png new file mode 100644 index 000000000000..a354beacb9bb Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_light_black.png new file mode 100644 index 000000000000..764d74a7ded2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_light_white.png new file mode 100644 index 000000000000..f834799ed859 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_open_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_testChooseLocationAction.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_testChooseLocationAction.png new file mode 100644 index 000000000000..ffd12d364928 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_testChooseLocationAction.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_testMoveOrCopy.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_testMoveOrCopy.png new file mode 100644 index 000000000000..25098f2820fa Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.FolderPickerActivityIT_testMoveOrCopy.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open.png new file mode 100644 index 000000000000..b32e2585dc84 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_black.png new file mode 100644 index 000000000000..8d45368eea59 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_blue.png new file mode 100644 index 000000000000..d24ac4a627d6 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_white.png new file mode 100644 index 000000000000..504ce4c9aa2a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_light_black.png new file mode 100644 index 000000000000..976a5b9c0f3b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_light_white.png new file mode 100644 index 000000000000..9c8c90fc6d92 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_open_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail.png new file mode 100644 index 000000000000..6b8b3d3b33b9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_black.png new file mode 100644 index 000000000000..3c9b13df13c3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_blue.png new file mode 100644 index 000000000000..ac4ec1b02d06 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_white.png new file mode 100644 index 000000000000..1c86ed3a504f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_light_black.png new file mode 100644 index 000000000000..ab5baaad7121 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_light_white.png new file mode 100644 index 000000000000..6b960a72bef5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ManageAccountsActivityIT_userInfoDetail_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check.png new file mode 100644 index 000000000000..0e9310c7e58f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_black.png new file mode 100644 index 000000000000..0c8484f10dc0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_blue.png new file mode 100644 index 000000000000..8402d80946a8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_white.png new file mode 100644 index 000000000000..c52ed2377b94 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_light_black.png new file mode 100644 index 000000000000..59c505f347dc Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_light_white.png new file mode 100644 index 000000000000..21e37c910324 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete.png new file mode 100644 index 000000000000..cf556b7259af Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_black.png new file mode 100644 index 000000000000..e2c6e00901c5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_blue.png new file mode 100644 index 000000000000..e02f1a068aab Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_white.png new file mode 100644 index 000000000000..bf346d3780ef Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_light_black.png new file mode 100644 index 000000000000..375f9c00a308 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_light_white.png new file mode 100644 index 000000000000..118f9ccb7b5b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request.png new file mode 100644 index 000000000000..8ae67d62bf19 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_black.png new file mode 100644 index 000000000000..1b60952b5dbe Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_blue.png new file mode 100644 index 000000000000..833f1d214cc9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_white.png new file mode 100644 index 000000000000..b12891f531dd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_light_black.png new file mode 100644 index 000000000000..1e2e88f240d3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_light_white.png new file mode 100644 index 000000000000..96fb3fcd5232 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_open.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_open.png new file mode 100644 index 000000000000..dd22ad19223e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_open.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_openMultiAccount.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_openMultiAccount.png new file mode 100644 index 000000000000..417d65b1e48e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_openMultiAccount.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_localFolderPickerMode.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_localFolderPickerMode.png new file mode 100644 index 000000000000..a463f861b32b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_localFolderPickerMode.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_noneSelected.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_noneSelected.png new file mode 100644 index 000000000000..97ec13325a98 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_noneSelected.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_open.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_open.png new file mode 100644 index 000000000000..4e1d996b9b42 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_open.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_search.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_search.png new file mode 100644 index 000000000000..a8960fa7d89a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_search.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_selectAll.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_selectAll.png new file mode 100644 index 000000000000..62949bb7fae2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UploadFilesActivityIT_selectAll.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail.png new file mode 100644 index 000000000000..db6f80f181d8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_black.png new file mode 100644 index 000000000000..619404bcce1a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_blue.png new file mode 100644 index 000000000000..3d9088c48f84 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_white.png new file mode 100644 index 000000000000..5aebab54c510 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_light_black.png new file mode 100644 index 000000000000..7477c8f2a005 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_light_white.png new file mode 100644 index 000000000000..8c8eeb641c9b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.activity.UserInfoActivityIT_fullUserInfoDetail_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog.png new file mode 100644 index 000000000000..a3db7ab8dca9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled.png new file mode 100644 index 000000000000..b1ba5c65760d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_black.png new file mode 100644 index 000000000000..0e49dae4655c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_blue.png new file mode 100644 index 000000000000..0e49dae4655c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_white.png new file mode 100644 index 000000000000..0e49dae4655c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_light_black.png new file mode 100644 index 000000000000..35c6f8481ddc Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_light_white.png new file mode 100644 index 000000000000..35c6f8481ddc Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialogWithStatusDisabled_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away.png new file mode 100644 index 000000000000..695e7fca1079 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_black.png new file mode 100644 index 000000000000..380eb47e8c29 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_blue.png new file mode 100644 index 000000000000..380eb47e8c29 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_white.png new file mode 100644 index 000000000000..380eb47e8c29 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_light_black.png new file mode 100644 index 000000000000..e91bc31681cf Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_light_white.png new file mode 100644 index 000000000000..e91bc31681cf Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_away_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_black.png new file mode 100644 index 000000000000..eefb1ee62c90 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_blue.png new file mode 100644 index 000000000000..eefb1ee62c90 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_white.png new file mode 100644 index 000000000000..eefb1ee62c90 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd.png new file mode 100644 index 000000000000..2f551cf6ce2b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_black.png new file mode 100644 index 000000000000..0176b9c55ba9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_blue.png new file mode 100644 index 000000000000..0176b9c55ba9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_white.png new file mode 100644 index 000000000000..0176b9c55ba9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_light_black.png new file mode 100644 index 000000000000..8eaf6071c9cd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_light_white.png new file mode 100644 index 000000000000..8eaf6071c9cd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_dnd_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun.png new file mode 100644 index 000000000000..74df5cc73e1b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_black.png new file mode 100644 index 000000000000..414ccad9c59f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_blue.png new file mode 100644 index 000000000000..414ccad9c59f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_white.png new file mode 100644 index 000000000000..414ccad9c59f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_light_black.png new file mode 100644 index 000000000000..bc93f99d9fb4 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_light_white.png new file mode 100644 index 000000000000..bc93f99d9fb4 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_fun_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_light_black.png new file mode 100644 index 000000000000..c4699f164bfd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_light_white.png new file mode 100644 index 000000000000..c4699f164bfd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline.png new file mode 100644 index 000000000000..a3db7ab8dca9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_black.png new file mode 100644 index 000000000000..eefb1ee62c90 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_blue.png new file mode 100644 index 000000000000..eefb1ee62c90 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_white.png new file mode 100644 index 000000000000..eefb1ee62c90 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_light_black.png new file mode 100644 index 000000000000..c4699f164bfd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_light_white.png new file mode 100644 index 000000000000..c4699f164bfd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_offline_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online.png new file mode 100644 index 000000000000..47a365dbfe41 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_black.png new file mode 100644 index 000000000000..05a0e82bb3e0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_blue.png new file mode 100644 index 000000000000..05a0e82bb3e0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_white.png new file mode 100644 index 000000000000..05a0e82bb3e0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_light_black.png new file mode 100644 index 000000000000..7cbb6f53d95d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_light_white.png new file mode 100644 index 000000000000..7cbb6f53d95d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testAccountChooserDialog_online_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet.png new file mode 100644 index 000000000000..8e618f1c30f1 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_black.png new file mode 100644 index 000000000000..dfd127399e4f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_blue.png new file mode 100644 index 000000000000..dfd127399e4f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_white.png new file mode 100644 index 000000000000..dfd127399e4f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_light_black.png new file mode 100644 index 000000000000..9a2628d72ba6 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_light_white.png new file mode 100644 index 000000000000..9a2628d72ba6 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testBottomSheet_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithOneAction.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithOneAction.png new file mode 100644 index 000000000000..6c51e11ace51 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithOneAction.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithThreeAction.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithThreeAction.png new file mode 100644 index 000000000000..bbadc48e4378 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithThreeAction.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithThreeActionRTL.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithThreeActionRTL.png new file mode 100644 index 000000000000..8366a9d6a932 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithThreeActionRTL.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithTwoAction.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithTwoAction.png new file mode 100644 index 000000000000..0a1d6b9d6e49 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testConfirmationDialogWithTwoAction.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testEnforcedPasswordDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testEnforcedPasswordDialog.png new file mode 100644 index 000000000000..004590868963 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testEnforcedPasswordDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testFileActionsBottomSheet.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testFileActionsBottomSheet.png new file mode 100644 index 000000000000..de41df933ade Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testFileActionsBottomSheet.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog.png new file mode 100644 index 000000000000..126a08fd1daf Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_black.png new file mode 100644 index 000000000000..a05c4b43a67c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_blue.png new file mode 100644 index 000000000000..a05c4b43a67c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_white.png new file mode 100644 index 000000000000..a05c4b43a67c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_light_black.png new file mode 100644 index 000000000000..36dba6767394 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_light_white.png new file mode 100644 index 000000000000..36dba6767394 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testLoadingDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog.png new file mode 100644 index 000000000000..941ef2a75c08 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_black.png new file mode 100644 index 000000000000..b0a6aa05e0a4 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_blue.png new file mode 100644 index 000000000000..2e29eb1e4d59 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_white.png new file mode 100644 index 000000000000..2e29eb1e4d59 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_light_black.png new file mode 100644 index 000000000000..81853f275f86 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_light_white.png new file mode 100644 index 000000000000..8847cef1ad01 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testNewFolderDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testOptionalPasswordDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testOptionalPasswordDialog.png new file mode 100644 index 000000000000..3b12b3e180bf Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testOptionalPasswordDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testProfileBottomSheet.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testProfileBottomSheet.png new file mode 100644 index 000000000000..004bb415aedc Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testProfileBottomSheet.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog.png new file mode 100644 index 000000000000..1d8bf529e057 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_black.png new file mode 100644 index 000000000000..996d430e17a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_blue.png new file mode 100644 index 000000000000..996d430e17a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_white.png new file mode 100644 index 000000000000..996d430e17a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_light_black.png new file mode 100644 index 000000000000..4b98224d13d9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_light_white.png new file mode 100644 index 000000000000..4b98224d13d9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFileDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog.png new file mode 100644 index 000000000000..8feba86853c0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_black.png new file mode 100644 index 000000000000..5e81324e51ea Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_blue.png new file mode 100644 index 000000000000..5e81324e51ea Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_white.png new file mode 100644 index 000000000000..5e81324e51ea Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_light_black.png new file mode 100644 index 000000000000..ae3c10105b86 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_light_white.png new file mode 100644 index 000000000000..ae3c10105b86 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFilesDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog.png new file mode 100644 index 000000000000..0f890bebc3c1 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_black.png new file mode 100644 index 000000000000..2b7e033470ad Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_blue.png new file mode 100644 index 000000000000..2b7e033470ad Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_white.png new file mode 100644 index 000000000000..2b7e033470ad Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_light_black.png new file mode 100644 index 000000000000..ab0580ec09a1 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_light_white.png new file mode 100644 index 000000000000..ab0580ec09a1 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFolderDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog.png new file mode 100644 index 000000000000..8feba86853c0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_black.png new file mode 100644 index 000000000000..5e81324e51ea Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_blue.png new file mode 100644 index 000000000000..5e81324e51ea Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_white.png new file mode 100644 index 000000000000..5e81324e51ea Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_light_black.png new file mode 100644 index 000000000000..ae3c10105b86 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_light_white.png new file mode 100644 index 000000000000..ae3c10105b86 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRemoveFoldersDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog.png new file mode 100644 index 000000000000..8705ea84fb5e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_black.png new file mode 100644 index 000000000000..4c6f318f4308 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_blue.png new file mode 100644 index 000000000000..225ffb05d1b1 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_white.png new file mode 100644 index 000000000000..225ffb05d1b1 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_light_black.png new file mode 100644 index 000000000000..1472223440a5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_light_white.png new file mode 100644 index 000000000000..8d93463ee5da Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testRenameFileDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testSslUntrustedCertDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testSslUntrustedCertDialog.png new file mode 100644 index 000000000000..24b3f52fbb56 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testSslUntrustedCertDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testStoragePermissionDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testStoragePermissionDialog.png new file mode 100644 index 000000000000..7e4b3f586510 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentIT_testStoragePermissionDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testAccountChooserDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testAccountChooserDialog.png new file mode 100644 index 000000000000..cbab280289e4 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testAccountChooserDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testLoadingDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testLoadingDialog.png new file mode 100644 index 000000000000..4a35c427dba5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testLoadingDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testNewFolderDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testNewFolderDialog.png new file mode 100644 index 000000000000..441d947a6f94 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testNewFolderDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFileDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFileDialog.png new file mode 100644 index 000000000000..b8dedb7d50ce Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFileDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFilesDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFilesDialog.png new file mode 100644 index 000000000000..acadc286142f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFilesDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFolderDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFolderDialog.png new file mode 100644 index 000000000000..6ca09361074d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFolderDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFoldersDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFoldersDialog.png new file mode 100644 index 000000000000..acadc286142f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRemoveFoldersDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRenameFileDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRenameFileDialog.png new file mode 100644 index 000000000000..2e517af0c836 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.DialogFragmentTest_testRenameFileDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialogDifferentTypes_Screenshot.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialogDifferentTypes_Screenshot.png new file mode 100644 index 000000000000..fcec93d81b20 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialogDifferentTypes_Screenshot.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_Screenshot.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_Screenshot.png new file mode 100644 index 000000000000..fcec93d81b20 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_Screenshot.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_black.png new file mode 100644 index 000000000000..aa423a97d6fe Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_blue.png new file mode 100644 index 000000000000..aa423a97d6fe Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_white.png new file mode 100644 index 000000000000..aa423a97d6fe Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_light_black.png new file mode 100644 index 000000000000..41c7ad94f3f7 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_light_white.png new file mode 100644 index 000000000000..41c7ad94f3f7 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendFilesDialogTest_showDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog.png new file mode 100644 index 000000000000..2526f3183c8a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_black.png new file mode 100644 index 000000000000..472466eb5b62 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_blue.png new file mode 100644 index 000000000000..472466eb5b62 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_white.png new file mode 100644 index 000000000000..562136a415dd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_light_black.png new file mode 100644 index 000000000000..d5bf6d8a426e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_light_white.png new file mode 100644 index 000000000000..17f6105fdac8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SendShareDialogTest_showDialog_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error.png new file mode 100644 index 000000000000..519a09839eea Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_black.png new file mode 100644 index 000000000000..3fcc14f54a76 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_blue.png new file mode 100644 index 000000000000..3fcc14f54a76 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_white.png new file mode 100644 index 000000000000..631137e20bcb Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_light_black.png new file mode 100644 index 000000000000..4c97474349c5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_light_white.png new file mode 100644 index 000000000000..d79cc4e97a7a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_error_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic.png new file mode 100644 index 000000000000..1ad442cbbbaf Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_black.png new file mode 100644 index 000000000000..951f517ad8a8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_blue.png new file mode 100644 index 000000000000..4ee10a4f58bf Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_white.png new file mode 100644 index 000000000000..2b62362accdf Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_light_black.png new file mode 100644 index 000000000000..f97fa92b9dc7 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_light_white.png new file mode 100644 index 000000000000..f97e27060568 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT_showMnemonic_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragmentTest_showNotEnoughSpaceDialogForFile.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragmentTest_showNotEnoughSpaceDialogForFile.png new file mode 100644 index 000000000000..4f329783e33c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragmentTest_showNotEnoughSpaceDialogForFile.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragmentTest_showNotEnoughSpaceDialogForFolder.png b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragmentTest_showNotEnoughSpaceDialogForFolder.png new file mode 100644 index 000000000000..1257bde35868 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragmentTest_showNotEnoughSpaceDialogForFolder.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars.png new file mode 100644 index 000000000000..df3df20dff58 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus.png new file mode 100644 index 000000000000..c8ab6125e68f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_black.png new file mode 100644 index 000000000000..3df457cd06de Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_blue.png new file mode 100644 index 000000000000..3df457cd06de Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_white.png new file mode 100644 index 000000000000..3df457cd06de Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_light_black.png new file mode 100644 index 000000000000..c8ab6125e68f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_light_white.png new file mode 100644 index 000000000000..c8ab6125e68f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatarsWithStatus_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_black.png new file mode 100644 index 000000000000..12c02d54c47a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_blue.png new file mode 100644 index 000000000000..12c02d54c47a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_white.png new file mode 100644 index 000000000000..12c02d54c47a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_light_black.png new file mode 100644 index 000000000000..ace71f3e38d2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_light_white.png new file mode 100644 index 000000000000..ace71f3e38d2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.AvatarIT_showAvatars_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarAndContactsList.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarAndContactsList.png new file mode 100644 index 000000000000..9343c4fc283b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarAndContactsList.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarList.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarList.png new file mode 100644 index 000000000000..7dc05cac41c9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarList.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showContactList.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showContactList.png new file mode 100644 index 000000000000..1531dddecde7 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showContactList.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showLoading.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showLoading.png new file mode 100644 index 000000000000..bd17b40c6ee8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showLoading.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading.png new file mode 100644 index 000000000000..63844893acaa Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_black.png new file mode 100644 index 000000000000..9741dfe64259 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_blue.png new file mode 100644 index 000000000000..9741dfe64259 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_white.png new file mode 100644 index 000000000000..9741dfe64259 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_light_black.png new file mode 100644 index 000000000000..63844893acaa Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_light_white.png new file mode 100644 index 000000000000..63844893acaa Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.ContactListFragmentIT_showContactListFragmentLoading_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities.png new file mode 100644 index 000000000000..73192a79800d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError.png new file mode 100644 index 000000000000..2b6b39e4f757 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_black.png new file mode 100644 index 000000000000..3df384bfd6aa Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_blue.png new file mode 100644 index 000000000000..a380ae5eba8c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_white.png new file mode 100644 index 000000000000..9111644d39db Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_light_black.png new file mode 100644 index 000000000000..fc67ca48e1bf Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_light_white.png new file mode 100644 index 000000000000..614334184ed8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesError_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone.png new file mode 100644 index 000000000000..7befbd3576a4 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_black.png new file mode 100644 index 000000000000..e16a4980e9db Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_blue.png new file mode 100644 index 000000000000..296f881aee7f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_white.png new file mode 100644 index 000000000000..f1f1e9869c14 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_light_black.png new file mode 100644 index 000000000000..93e06caf80de Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_light_white.png new file mode 100644 index 000000000000..9d05bfab8fda Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivitiesNone_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_black.png new file mode 100644 index 000000000000..2f3abdf027be Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_blue.png new file mode 100644 index 000000000000..8175a2e32af3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_white.png new file mode 100644 index 000000000000..913e56f2ba52 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_light_black.png new file mode 100644 index 000000000000..0dde55a7291c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_light_white.png new file mode 100644 index 000000000000..a939b688746c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsActivities_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing.png new file mode 100644 index 000000000000..04a448a926b8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_black.png new file mode 100644 index 000000000000..742fc5870722 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_blue.png new file mode 100644 index 000000000000..cd34ffd2d0ca Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_white.png new file mode 100644 index 000000000000..8240f9509c9b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_light_black.png new file mode 100644 index 000000000000..34038df7cbfd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_light_white.png new file mode 100644 index 000000000000..96a770766376 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetailsSharing_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetails_Activities.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetails_Activities.png new file mode 100644 index 000000000000..a95c5c6de1c0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetails_Activities.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetails_Sharing.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetails_Sharing.png new file mode 100644 index 000000000000..555209fd7077 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showDetails_Sharing.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment.png new file mode 100644 index 000000000000..9295a4ecf5b5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_black.png new file mode 100644 index 000000000000..2cce494ee469 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_blue.png new file mode 100644 index 000000000000..2cce494ee469 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_white.png new file mode 100644 index 000000000000..2cce494ee469 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_light_black.png new file mode 100644 index 000000000000..d0062780ce86 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_light_white.png new file mode 100644 index 000000000000..d0062780ce86 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailActivitiesFragment_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailDetailsFragment.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailDetailsFragment.png new file mode 100644 index 000000000000..6f4fd284e06e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailDetailsFragment.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment.png new file mode 100644 index 000000000000..30e9b9325982 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_black.png new file mode 100644 index 000000000000..28111501e0a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_blue.png new file mode 100644 index 000000000000..28111501e0a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_white.png new file mode 100644 index 000000000000..28111501e0a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_light_black.png new file mode 100644 index 000000000000..d0e3d8e0b775 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_light_white.png new file mode 100644 index 000000000000..d0e3d8e0b775 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailSharingFragment_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesDownloadLimit.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesDownloadLimit.png new file mode 100644 index 000000000000..ac96f0cce34c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesDownloadLimit.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes.png new file mode 100644 index 000000000000..cc50640110c8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_black.png new file mode 100644 index 000000000000..b9012ab0108e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_blue.png new file mode 100644 index 000000000000..b9012ab0108e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_white.png new file mode 100644 index 000000000000..f686f0eaca3b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_light_black.png new file mode 100644 index 000000000000..440609826ec2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_light_white.png new file mode 100644 index 000000000000..29262d82acd7 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileAllShareTypes_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone.png new file mode 100644 index 000000000000..365a99e5dafd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_black.png new file mode 100644 index 000000000000..b2e952bfb9b8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_blue.png new file mode 100644 index 000000000000..b2e952bfb9b8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_white.png new file mode 100644 index 000000000000..b2e952bfb9b8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_light_black.png new file mode 100644 index 000000000000..f7929097a00c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_light_white.png new file mode 100644 index 000000000000..f7929097a00c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileNone_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed.png new file mode 100644 index 000000000000..341b02c88006 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_black.png new file mode 100644 index 000000000000..28111501e0a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_blue.png new file mode 100644 index 000000000000..28111501e0a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_white.png new file mode 100644 index 000000000000..28111501e0a3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_light_black.png new file mode 100644 index 000000000000..d0e3d8e0b775 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_light_white.png new file mode 100644 index 000000000000..d0e3d8e0b775 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listSharesFileResharingNotAllowed_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_allShareTypes.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_allShareTypes.png new file mode 100644 index 000000000000..c9634d7fc223 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_allShareTypes.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_none.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_none.png new file mode 100644 index 000000000000..eed7aa4f5556 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_none.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_resharing_not_allowed.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_resharing_not_allowed.png new file mode 100644 index 000000000000..7423f57ced0b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_listShares_file_resharing_not_allowed.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_publicLink_optionMenu.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_publicLink_optionMenu.png new file mode 100644 index 000000000000..eed7aa4f5556 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT_publicLink_optionMenu.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GalleryFragmentIT_showEmpty.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GalleryFragmentIT_showEmpty.png new file mode 100644 index 000000000000..587ec2f2d624 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GalleryFragmentIT_showEmpty.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GalleryFragmentIT_showGallery.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GalleryFragmentIT_showGallery.png new file mode 100644 index 000000000000..7a45f94b068c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GalleryFragmentIT_showGallery.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showEmpty.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showEmpty.png new file mode 100644 index 000000000000..a4a82a352d8f Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showEmpty.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showGroupfolder.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showGroupfolder.png new file mode 100644 index 000000000000..19389970038d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showGroupfolder.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showGroupfolders.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showGroupfolders.png new file mode 100644 index 000000000000..939279deac25 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.GroupfolderListFragmentIT_showGroupfolders.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToCircle.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToCircle.png new file mode 100644 index 000000000000..5f325fe862f2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToCircle.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToGroup.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToGroup.png new file mode 100644 index 000000000000..a4fa4f5bb1b9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToGroup.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToUser.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToUser.png new file mode 100644 index 000000000000..ed1549001c1a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareToUser.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareViaLink.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareViaLink.png new file mode 100644 index 000000000000..afd2879e1682 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentIT_createAndShowShareViaLink.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles.png new file mode 100644 index 000000000000..7e58a2293c47 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_black.png new file mode 100644 index 000000000000..9833a7abc367 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_blue.png new file mode 100644 index 000000000000..9833a7abc367 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_white.png new file mode 100644 index 000000000000..9833a7abc367 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_light_black.png new file mode 100644 index 000000000000..745ac5c3fc0b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_light_white.png new file mode 100644 index 000000000000..745ac5c3fc0b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFiles_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFolderTypes.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFolderTypes.png new file mode 100644 index 000000000000..e00c58cc90f4 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showFolderTypes.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showOneFile.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showOneFile.png new file mode 100644 index 000000000000..696d50ef0f86 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showOneFile.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace.png new file mode 100644 index 000000000000..c8abed0013a9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_black.png new file mode 100644 index 000000000000..0b9524f66eec Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_blue.png new file mode 100644 index 000000000000..0b9524f66eec Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_white.png new file mode 100644 index 000000000000..0b9524f66eec Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_light_black.png new file mode 100644 index 000000000000..9abc237a68c0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_light_white.png new file mode 100644 index 000000000000..9abc237a68c0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showRichWorkspace_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles.png new file mode 100644 index 000000000000..8f9e17c4bf22 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_black.png new file mode 100644 index 000000000000..153ed95db150 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_blue.png new file mode 100644 index 000000000000..153ed95db150 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_white.png new file mode 100644 index 000000000000..05cb3b2f2245 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_light_black.png new file mode 100644 index 000000000000..435df38e1773 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_light_white.png new file mode 100644 index 000000000000..26cbbe26642b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT_showSharedFiles_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.SharedListFragmentIT_showSharedFiles.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.SharedListFragmentIT_showSharedFiles.png new file mode 100644 index 000000000000..4963d5325fa3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.SharedListFragmentIT_showSharedFiles.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty.png new file mode 100644 index 000000000000..bb3975b6adcc Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_black.png new file mode 100644 index 000000000000..80a9a974f817 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_blue.png new file mode 100644 index 000000000000..80a9a974f817 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_white.png new file mode 100644 index 000000000000..80a9a974f817 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_light_black.png new file mode 100644 index 000000000000..30e74ed6d0ac Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_light_white.png new file mode 100644 index 000000000000..30e74ed6d0ac Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_empty_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error.png new file mode 100644 index 000000000000..e2ec6b94b6f0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_black.png new file mode 100644 index 000000000000..0e8c5518e3f2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_blue.png new file mode 100644 index 000000000000..0e8c5518e3f2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_white.png new file mode 100644 index 000000000000..0e8c5518e3f2 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_light_black.png new file mode 100644 index 000000000000..1150a3fe64d3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_light_white.png new file mode 100644 index 000000000000..1150a3fe64d3 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_error_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications.png new file mode 100644 index 000000000000..ff2419da15a7 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_black.png new file mode 100644 index 000000000000..8fdf4c8e14a4 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_blue.png new file mode 100644 index 000000000000..574185bb33d5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_white.png new file mode 100644 index 000000000000..f0cbda7f9a87 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_light_black.png new file mode 100644 index 000000000000..8da4376f4738 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_light_white.png new file mode 100644 index 000000000000..4a473e7873d9 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT_showNotifications_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewBitmapScreenshotIT_showBitmap.png b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewBitmapScreenshotIT_showBitmap.png new file mode 100644 index 000000000000..f159bacba6a8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewBitmapScreenshotIT_showBitmap.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_corruptImage.png b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_corruptImage.png new file mode 100644 index 000000000000..4b4233de02d0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_corruptImage.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_corruptImage_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_corruptImage_dark_blue.png new file mode 100644 index 000000000000..5de752f43315 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_corruptImage_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_validImage.png b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_validImage.png new file mode 100644 index 000000000000..85b58a4033ef Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_validImage.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_validImage_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_validImage_dark_blue.png new file mode 100644 index 000000000000..5de752f43315 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewImageFragmentIT_validImage_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewTextFileFragmentTest_displayJavaSnippetFile.png b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewTextFileFragmentTest_displayJavaSnippetFile.png new file mode 100644 index 000000000000..3b286598637a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewTextFileFragmentTest_displayJavaSnippetFile.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewTextFileFragmentTest_displaySimpleTextFile.png b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewTextFileFragmentTest_displaySimpleTextFile.png new file mode 100644 index 000000000000..22c0729ed11b Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.PreviewTextFileFragmentTest_displaySimpleTextFile.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.preview.pdf.PreviewPdfFragmentScreenshotIT_showPdf.png b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.pdf.PreviewPdfFragmentScreenshotIT_showPdf.png new file mode 100644 index 000000000000..7a7493cdb4e8 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.preview.pdf.PreviewPdfFragmentScreenshotIT_showPdf.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_differentUser.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_differentUser.png new file mode 100644 index 000000000000..dc7e9793ee1a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_differentUser.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty.png new file mode 100644 index 000000000000..12c7db3c2255 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_black.png new file mode 100644 index 000000000000..8931fa75a205 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_blue.png new file mode 100644 index 000000000000..8931fa75a205 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_white.png new file mode 100644 index 000000000000..8931fa75a205 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_light_black.png new file mode 100644 index 000000000000..13f8a3f5059e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_light_white.png new file mode 100644 index 000000000000..13f8a3f5059e Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_empty_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error.png new file mode 100644 index 000000000000..dc7e9793ee1a Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_black.png new file mode 100644 index 000000000000..5fb07622c1f0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_blue.png new file mode 100644 index 000000000000..5fb07622c1f0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_white.png new file mode 100644 index 000000000000..5fb07622c1f0 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_light_black.png new file mode 100644 index 000000000000..d781f06bbe01 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_light_white.png new file mode 100644 index 000000000000..d781f06bbe01 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_error_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files.png new file mode 100644 index 000000000000..e07bb83404e5 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_black.png new file mode 100644 index 000000000000..b882319f1ceb Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_blue.png new file mode 100644 index 000000000000..3e3356ee1c62 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_white.png new file mode 100644 index 000000000000..8ff7b498271d Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_light_black.png new file mode 100644 index 000000000000..d9975f43328c Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_light_white.png new file mode 100644 index 000000000000..d1e4ceed6423 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_files_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading.png new file mode 100644 index 000000000000..b59f3617ec82 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_black.png new file mode 100644 index 000000000000..58ac9df8bcdd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_blue.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_blue.png new file mode 100644 index 000000000000..58ac9df8bcdd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_blue.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_white.png new file mode 100644 index 000000000000..58ac9df8bcdd Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_dark_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_light_black.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_light_black.png new file mode 100644 index 000000000000..ee2a02b6c958 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_light_black.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_light_white.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_light_white.png new file mode 100644 index 000000000000..ee2a02b6c958 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_loading_light_white.png differ diff --git a/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_normalUser.png b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_normalUser.png new file mode 100644 index 000000000000..cf104f97ff45 Binary files /dev/null and b/app/screenshots/generic/debug/com.owncloud.android.ui.trashbin.TrashbinActivityIT_normalUser.png differ diff --git a/app/screenshots/generic/debug/richworkspaces_dark.png b/app/screenshots/generic/debug/richworkspaces_dark.png new file mode 100644 index 000000000000..5a30083429f7 Binary files /dev/null and b/app/screenshots/generic/debug/richworkspaces_dark.png differ diff --git a/app/screenshots/generic/debug/richworkspaces_light.png b/app/screenshots/generic/debug/richworkspaces_light.png new file mode 100644 index 000000000000..9ebdf080a044 Binary files /dev/null and b/app/screenshots/generic/debug/richworkspaces_light.png differ diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000000..754410920f45 --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/androidTest/assets/calendar.ics b/app/src/androidTest/assets/calendar.ics new file mode 100644 index 000000000000..421cfeb464f5 --- /dev/null +++ b/app/src/androidTest/assets/calendar.ics @@ -0,0 +1,133 @@ +BEGIN:VCALENDAR +PRODID:-//dummy@gmail.com//iCal Import/Export 3.18.0 Alpha1// + EN +VERSION:2.0 +METHOD:PUBLISH +CALSCALE:GREGORIAN +X-WR-TIMEZONE:UTC +BEGIN:VTIMEZONE +TZID:Europe/Berlin +LAST-MODIFIED:20201011T015911Z +TZURL:http://tzurl.org/zoneinfo/Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +X-PROLEPTIC-TZNAME:LMT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+005328 +TZOFFSETTO:+0100 +DTSTART:18930401T000000 +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19160430T230000 +RDATE:19400401T020000 +RDATE:19430329T020000 +RDATE:19460414T020000 +RDATE:19470406T030000 +RDATE:19480418T020000 +RDATE:19490410T020000 +RDATE:19800406T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19161001T010000 +RDATE:19421102T030000 +RDATE:19431004T030000 +RDATE:19441002T030000 +RDATE:19451118T030000 +RDATE:19461007T030000 +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19170416T020000 +RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19170917T030000 +RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19440403T020000 +RRULE:FREQ=YEARLY;UNTIL=19450402T010000Z;BYMONTH=4;BYDAY=1MO +END:DAYLIGHT +BEGIN:DAYLIGHT +TZNAME:CEMT +TZOFFSETFROM:+0200 +TZOFFSETTO:+0300 +DTSTART:19450524T010000 +RDATE:19470511T020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0300 +TZOFFSETTO:+0200 +DTSTART:19450924T030000 +RDATE:19470629T030000 +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +DTSTART:19460101T000000 +RDATE:19800101T000000 +END:STANDARD +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19471005T030000 +RRULE:FREQ=YEARLY;UNTIL=19491002T010000Z;BYMONTH=10;BYDAY=1SU +END:STANDARD +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19800928T030000 +RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20210820T083606Z +UID:16294485666795adb8b4b08e94d3cb4445e1e3ee18fd9@nextcloud.com +SUMMARY:Test event +DESCRIPTION:Test event + +ORGANIZER:mailto:dummy@gmail.com +LOCATION: +STATUS:CONFIRMED +DTSTART;TZID=Europe/Berlin:20210806T090000 +DTEND;TZID=Europe/Berlin:20210806T100000 + +BEGIN:VALARM +TRIGGER:-PT30M +ACTION:DISPLAY +DESCRIPTION:Test event +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/app/src/androidTest/assets/christine.jpg b/app/src/androidTest/assets/christine.jpg new file mode 100644 index 000000000000..a8dd31fa0a06 Binary files /dev/null and b/app/src/androidTest/assets/christine.jpg differ diff --git a/app/src/androidTest/assets/credentials.json b/app/src/androidTest/assets/credentials.json new file mode 100644 index 000000000000..8e660495625c --- /dev/null +++ b/app/src/androidTest/assets/credentials.json @@ -0,0 +1,4 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3wSNveXIhRsKl86pUnL7\n/AIAH+IJya5vqP0lv+yCBkd728szrLRYRWxPNC4VDbzyRHBr0RWj0ibsLJvU2OeF\n5p4er1tMIGgB0AEwiuDXBBz/RrxjPdhlilq7mvvqeUS2M3t5iroIxM6VEGQrhVrb\nb3U+7c6Lt7dIHAHEVOXnZiHYhhhduEmIzbsrAZFuMjlnWXTiMhuuWBf6t1nPyCHa\noA96loWibbvIsMegC73J3Ej5sgLkz/TjlrYmv6p3RGAEs74KHfggy4Fzw9TxBAAY\nyIX0NY8Rhb10XKrOSXrvRYuL/wkJ3P5XVK/NfsuLKbrhuUjDSgKplY9xCtOSaEPJ\nVQIDAQAB\n-----END PUBLIC KEY-----", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIC9DCCAdygAwIBAgIBADANBgkqhkiG9w0BAQUFADATMREwDwYDVQQDDAhuYXJy\nYXRvcjAeFw0yNDA1MjcxMzEyNDVaFw00NDA1MjIxMzEyNDVaMBMxETAPBgNVBAMM\nCG5hcnJhdG9yMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjA2EEYeN\nc3BdVDPkJK/AWPB1kd9sWAonZt/V4sbAE6fGy4qU21xfInZQaMHyhqdMXga10juE\nJLPKuyyRz+qijASryW+WzCJ3A9QeHHO+CiLc09yuB80JRpH0oHsol6WrdO1n5zuH\nlPtAdCwi4OeRmvazfBysbP2gaUl7DxackqbMei8a0MoyDxUB11hp0tpyYAU1/sXZ\nLGh4R4q4/F2KlSeYY9D62OJ8wNTgv9AYF/HRxXxWmVftB1En/DdvVr1zJGraHiRm\nQbaEnmsSGK8QHHm4h37cfD5f7rW1WO5A8KyJKwluOIXjMfL1YijAPpNW6EHhSlfT\n5RVLCHxvrzMHewIDAQABo1MwUTAdBgNVHQ4EFgQUzT6RHEHtpdjr8N3ABJK0wpFt\n1PMwHwYDVR0jBBgwFoAUzT6RHEHtpdjr8N3ABJK0wpFt1PMwDwYDVR0TAQH/BAUw\nAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAJ1q3CSBHrLauOZAD56BeElgh/ahbegsE\nZ4w7q4FdhkixLIwe6yrMmSvpNTuxRDHUrVLXxQmN0X3Yb7BLNXnnIUfH9EozaV7p\nYjOLWD2XCfLJmpGIBVvqZhyZrTl69jkBaVHF78aj1vt+qKihHUAVnG+qGH0PFms+\nG0KyY8bNYg+2HQiSTva1kgGPUA/8nQNj3lwi+r03tgqbw88fQKRPeMUJWdh/yV9U\noBdPHt+TBsUFZQZP3lBBS9lYhDT9fNoGX12WPAEUjYNhHVX+Qdup8Mg3aUMITXXJ\nvlGsN1SknlLoN0RwBFbyH9BCzqAdEIj5qQM3YDzIIyyy6AAnswNEUg==\n-----END CERTIFICATE-----" +} diff --git a/src/androidTest/assets/decrypted.json b/app/src/androidTest/assets/decrypted.json similarity index 100% rename from src/androidTest/assets/decrypted.json rename to app/src/androidTest/assets/decrypted.json diff --git a/src/androidTest/assets/encrypted.json b/app/src/androidTest/assets/encrypted.json similarity index 100% rename from src/androidTest/assets/encrypted.json rename to app/src/androidTest/assets/encrypted.json diff --git a/src/androidTest/assets/encrypted/ia7OEEEyXMoRa1QWQk8r b/app/src/androidTest/assets/encrypted/ia7OEEEyXMoRa1QWQk8r similarity index 100% rename from src/androidTest/assets/encrypted/ia7OEEEyXMoRa1QWQk8r rename to app/src/androidTest/assets/encrypted/ia7OEEEyXMoRa1QWQk8r diff --git a/src/androidTest/assets/encrypted/n9WXAIXO2wRY4R8nXwmo b/app/src/androidTest/assets/encrypted/n9WXAIXO2wRY4R8nXwmo similarity index 100% rename from src/androidTest/assets/encrypted/n9WXAIXO2wRY4R8nXwmo rename to app/src/androidTest/assets/encrypted/n9WXAIXO2wRY4R8nXwmo diff --git a/app/src/androidTest/assets/gps.jpg b/app/src/androidTest/assets/gps.jpg new file mode 100644 index 000000000000..b10ff38eb505 Binary files /dev/null and b/app/src/androidTest/assets/gps.jpg differ diff --git a/src/androidTest/assets/ia7OEEEyXMoRa1QWQk8r b/app/src/androidTest/assets/ia7OEEEyXMoRa1QWQk8r similarity index 100% rename from src/androidTest/assets/ia7OEEEyXMoRa1QWQk8r rename to app/src/androidTest/assets/ia7OEEEyXMoRa1QWQk8r diff --git a/app/src/androidTest/assets/image.jpg b/app/src/androidTest/assets/image.jpg new file mode 100644 index 000000000000..4fdbefd23804 Binary files /dev/null and b/app/src/androidTest/assets/image.jpg differ diff --git a/app/src/androidTest/assets/imageFile.png b/app/src/androidTest/assets/imageFile.png new file mode 100644 index 000000000000..3becc192b554 Binary files /dev/null and b/app/src/androidTest/assets/imageFile.png differ diff --git a/app/src/androidTest/assets/java.md b/app/src/androidTest/assets/java.md new file mode 100644 index 000000000000..bb5b5059db36 --- /dev/null +++ b/app/src/androidTest/assets/java.md @@ -0,0 +1,7 @@ +Java: + +```java +private String getAppProcessName() { + return Application.getProcessName(); +} +``` diff --git a/src/androidTest/assets/n9WXAIXO2wRY4R8nXwmo b/app/src/androidTest/assets/n9WXAIXO2wRY4R8nXwmo similarity index 100% rename from src/androidTest/assets/n9WXAIXO2wRY4R8nXwmo rename to app/src/androidTest/assets/n9WXAIXO2wRY4R8nXwmo diff --git a/app/src/androidTest/assets/paulette.jpg b/app/src/androidTest/assets/paulette.jpg new file mode 100644 index 000000000000..acaef31e3235 Binary files /dev/null and b/app/src/androidTest/assets/paulette.jpg differ diff --git a/src/androidTest/assets/srEPevoPqPZpPEaeDnS3 b/app/src/androidTest/assets/srEPevoPqPZpPEaeDnS3 similarity index 100% rename from src/androidTest/assets/srEPevoPqPZpPEaeDnS3 rename to app/src/androidTest/assets/srEPevoPqPZpPEaeDnS3 diff --git a/app/src/androidTest/assets/test.pdf b/app/src/androidTest/assets/test.pdf new file mode 100644 index 000000000000..46782a41cd73 Binary files /dev/null and b/app/src/androidTest/assets/test.pdf differ diff --git a/app/src/androidTest/assets/vcard.vcf b/app/src/androidTest/assets/vcard.vcf new file mode 100644 index 000000000000..a0f3ead188af --- /dev/null +++ b/app/src/androidTest/assets/vcard.vcf @@ -0,0 +1,17 @@ +BEGIN:VCARD +VERSION:3.0 +N:Mustermann;Erika;;Dr.; +FN:Dr. Erika Mustermann +ORG:Wikimedia +ROLE:Kommunikation +TITLE:Redaktion & Gestaltung +PHOTO;VALUE=URL;TYPE=JPEG:http://commons.wikimedia.org/wiki/File:Erika_Mustermann_2010.jpg +TEL;TYPE=WORK,VOICE:+49 221 9999123 +TEL;TYPE=HOME,VOICE:+49 221 1234567 +ADR;TYPE=HOME:;;Heidestraße 17;Köln;;51147;Germany +EMAIL;TYPE=PREF,INTERNET:erika@mustermann.de +URL:http://de.wikipedia.org/ +REV:2014-03-01T22:11:10Z +END:VCARD + +## from https://de.wikipedia.org/wiki/VCard#vCard_3.0 on 27.07.2020 diff --git a/app/src/androidTest/assets/videoFile.mp4 b/app/src/androidTest/assets/videoFile.mp4 new file mode 100644 index 000000000000..52f6cb2f0b83 Binary files /dev/null and b/app/src/androidTest/assets/videoFile.mp4 differ diff --git a/app/src/androidTest/disabledTests/AuthenticatorActivityTest.java b/app/src/androidTest/disabledTests/AuthenticatorActivityTest.java new file mode 100644 index 000000000000..3f56bf33cbfb --- /dev/null +++ b/app/src/androidTest/disabledTests/AuthenticatorActivityTest.java @@ -0,0 +1,124 @@ +//* +// * Nextcloud - Android Client +// * +// * SPDX-FileCopyrightText: 2015 ownCloud Inc. +// * SPDX-License-Identifier: GPL-2.0-only +// */ +// +//package com.owncloud.android.authentication; +// +//import android.app.Activity; +//import android.content.Context; +//import android.content.Intent; +//import android.os.Bundle; +//import android.os.RemoteException; +//import android.support.test.InstrumentationRegistry; +//import android.support.test.rule.ActivityTestRule; +//import android.support.test.runner.AndroidJUnit4; +//import android.support.test.uiautomator.UiDevice; +//import android.test.suitebuilder.annotation.LargeTest; +// +//import com.owncloud.android.R; +// +//import org.junit.Before; +//import org.junit.Rule; +//import org.junit.Test; +//import org.junit.runner.RunWith; +// +//import java.lang.reflect.Field; +// +//import static android.support.test.espresso.Espresso.onView; +//import static android.support.test.espresso.action.ViewActions.click; +//import static android.support.test.espresso.action.ViewActions.closeSoftKeyboard; +//import static android.support.test.espresso.action.ViewActions.typeText; +//import static android.support.test.espresso.assertion.ViewAssertions.matches; +//import static android.support.test.espresso.matcher.ViewMatchers.isEnabled; +//import static android.support.test.espresso.matcher.ViewMatchers.withId; +//import static org.hamcrest.Matchers.not; +//import static org.junit.Assert.assertTrue; +// +//@RunWith(AndroidJUnit4.class) +//@LargeTest +//public class AuthenticatorActivityTest { +// +// public static final String EXTRA_ACTION = "ACTION"; +// public static final String EXTRA_ACCOUNT = "ACCOUNT"; +// +// private static final int WAIT_LOGIN = 5000; +// +// private static final String ERROR_MESSAGE = "Activity not finished"; +// private static final String RESULT_CODE = "mResultCode"; +// +// +// @Rule +// public ActivityTestRule mActivityRule = new ActivityTestRule( +// AuthenticatorActivity.class){ +// @Override +// protected Intent getActivityIntent() { +// +// Context targetContext = InstrumentationRegistry.getInstrumentation() +// .getTargetContext(); +// Intent result = new Intent(targetContext, AuthenticatorActivity.class); +// result.putExtra(EXTRA_ACTION, AuthenticatorActivity.ACTION_CREATE); +// result.putExtra(EXTRA_ACCOUNT, ""); +// return result; +// } +// }; +// +// @Before +// public void init(){ +// UiDevice uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); +// /*Point[] coordinates = new Point[4]; +// coordinates[0] = new Point(248, 1020); +// coordinates[1] = new Point(248, 429); +// coordinates[2] = new Point(796, 1020); +// coordinates[3] = new Point(796, 429);*/ +// try { +// if (!uiDevice.isScreenOn()) { +// uiDevice.wakeUp(); +// //uiDevice.swipe(coordinates, 10); +// } +// } catch (RemoteException e) { +// e.printStackTrace(); +// } +// } +// +// @Test +// public void checkLogin() +// throws InterruptedException, NoSuchFieldException, IllegalArgumentException, IllegalAccessException { +// Bundle arguments = InstrumentationRegistry.getArguments(); +// +// // Get values passed +// String testUser = arguments.getString("TEST_USER"); +// String testPassword = arguments.getString("TEST_PASSWORD"); +// String testServerURL = arguments.getString("TEST_SERVER_URL"); +// +// // Check that login button is disabled +// onView(withId(R.id.buttonOK)) +// .check(matches(not(isEnabled()))); +// +// // Type server url +// onView(withId(R.id.hostUrlInput)) +// .perform(typeText(testServerURL), closeSoftKeyboard()); +// onView(withId(R.id.account_username)).perform(click()); +// +// // Type user +// onView(withId(R.id.account_username)) +// .perform(typeText(testUser), closeSoftKeyboard()); +// +// // Type user pass +// onView(withId(R.id.account_password)) +// .perform(typeText(testPassword), closeSoftKeyboard()); +// onView(withId(R.id.buttonOK)).perform(click()); +// +// // Check that the Activity ends after clicking +// +// Thread.sleep(WAIT_LOGIN); +// Field f = Activity.class.getDeclaredField(RESULT_CODE); +// f.setAccessible(true); +// int mResultCode = f.getInt(mActivityRule.getActivity()); +// +// assertTrue(ERROR_MESSAGE, mResultCode == Activity.RESULT_OK); +// +// } +//} \ No newline at end of file diff --git a/src/androidTest/java/com/owncloud/android/uiautomator/InitialTest.java b/app/src/androidTest/disabledTests/uiautomator/InitialTest.java similarity index 76% rename from src/androidTest/java/com/owncloud/android/uiautomator/InitialTest.java rename to app/src/androidTest/disabledTests/uiautomator/InitialTest.java index 6b70cbafed59..b548770d52bc 100644 --- a/src/androidTest/java/com/owncloud/android/uiautomator/InitialTest.java +++ b/app/src/androidTest/disabledTests/uiautomator/InitialTest.java @@ -1,41 +1,27 @@ -/** - * ownCloud Android client application - *

- * Copyright (C) 2015 ownCloud Inc. - *

- * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - *

- * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with this program. If not, see . +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ - package com.owncloud.android.uiautomator; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.support.test.InstrumentationRegistry; -import android.support.test.filters.SdkSuppress; -import android.support.test.runner.AndroidJUnit4; -import android.support.test.uiautomator.By; -import android.support.test.uiautomator.UiDevice; -import android.support.test.uiautomator.UiObject; -import android.support.test.uiautomator.UiObjectNotFoundException; -import android.support.test.uiautomator.UiSelector; -import android.support.test.uiautomator.Until; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SdkSuppress; +import androidx.test.runner.AndroidJUnit4; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObject; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiSelector; import static org.hamcrest.CoreMatchers.notNullValue; import static org.junit.Assert.assertThat; @@ -67,7 +53,7 @@ public void checkPreconditions() { } /** - * Start owncloud app + * Start Nextcloud app */ @Test public void startAppFromHomeScreen() { diff --git a/app/src/androidTest/java/com/nextcloud/client/ActivitiesFragmentIT.kt b/app/src/androidTest/java/com/nextcloud/client/ActivitiesFragmentIT.kt new file mode 100644 index 000000000000..737f77c10169 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/ActivitiesFragmentIT.kt @@ -0,0 +1,161 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client + +import android.view.View +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.DrawerActions +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.lib.resources.activities.model.Activity +import com.owncloud.android.lib.resources.activities.model.RichElement +import com.owncloud.android.lib.resources.activities.model.RichObject +import com.owncloud.android.lib.resources.activities.models.PreviewObject +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.ui.fragment.ActivitiesFragment +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.ui.navigation.NavigatorScreen +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test +import java.util.GregorianCalendar + +class ActivitiesFragmentIT : AbstractIT() { + private val testClassName = "com.nextcloud.client.ActivitiesFragmentIT" + + private fun ActivityScenario.getFragment(): ActivitiesFragment? { + var fragment: ActivitiesFragment? = null + onActivity { activity -> + fragment = activity.supportFragmentManager + .findFragmentByTag(NavigatorScreen.Activities.tag) as? ActivitiesFragment + } + return fragment + } + + private fun launchActivitiesActivity(): ActivityScenario { + val intent = NavigatorActivity.intent( + ApplicationProvider.getApplicationContext(), + NavigatorScreen.Activities + ) + return ActivityScenario.launch(intent) + } + + @Test + @ScreenshotTest + fun openDrawer() { + launchActivitiesActivity().use { scenario -> + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()) + scenario.onActivity { sut -> + val screenShotName = createName("${testClassName}_openDrawer", "") + screenshotViaName(sut, screenShotName) + } + onView(withId(R.id.drawer_layout)).check(matches(isDisplayed())) + } + } + + @Test + @ScreenshotTest + fun loading() { + launchActivitiesActivity().use { scenario -> + val fragment = scenario.getFragment() ?: return + scenario.onActivity { + fragment.binding?.emptyList?.root?.visibility = View.GONE + fragment.binding?.swipeContainingList?.visibility = View.GONE + fragment.binding?.loadingContent?.visibility = View.VISIBLE + } + val screenShotName = createName("${testClassName}_loading", "") + onView(isRoot()).check(matches(isDisplayed())) + scenario.onActivity { sut -> screenshotViaName(sut, screenShotName) } + } + } + + @Test + @ScreenshotTest + fun empty() { + launchActivitiesActivity().use { scenario -> + val fragment = scenario.getFragment() ?: return + scenario.onActivity { + fragment.showActivities(mutableListOf(), nextcloudClient, -1) + fragment.setProgressIndicatorState(false) + } + val screenShotName = createName("${testClassName}_empty", "") + onView(isRoot()).check(matches(isDisplayed())) + scenario.onActivity { sut -> screenshotViaName(sut, screenShotName) } + } + } + + @Test + @ScreenshotTest + @SuppressWarnings("MagicNumber") + fun showActivities() { + val capability = OCCapability() + capability.versionMayor = 20 + fileDataStorageManager.saveCapabilities(capability) + + val date = GregorianCalendar() + date.set(2005, 4, 17, 10, 35, 30) + + val richObjectList: ArrayList = ArrayList() + richObjectList.add(RichObject("file", "abc", "text.txt", "/text.txt", "link", "tag")) + richObjectList.add(RichObject("file", "1", "text.txt", "/text.txt", "link", "tag")) + + val previewObjectList1: ArrayList = ArrayList() + previewObjectList1.add(PreviewObject(1, "source", "link", true, "text/plain", "view", "test1.txt")) + + val previewObjectList3: ArrayList = ArrayList() + previewObjectList3.add(PreviewObject(1, "source", "link", true, "image/jpg", "view", "test1.jpg")) + + val activities = mutableListOf( + Activity( + 1, date.time, date.time, "files", "file_changed", "user1", "user1", + "You changed text.txt", "", "icon", "link", "files", "1", "/text.txt", + previewObjectList1, RichElement("", richObjectList) + ), + Activity( + 1, date.time, date.time, "dav", "calendar_event", "user1", "user1", + "You have deleted calendar entry Appointment", "", "icon", "link", "calendar", + "35", "", ArrayList(), RichElement() + ), + Activity( + 1, date.time, date.time, "files", "file_changed", "user1", "user1", + "You changed image.jpg", "", "icon", "link", "files", "1", "/image.jpg", + previewObjectList3, RichElement("", richObjectList) + ) + ) + + launchActivitiesActivity().use { scenario -> + val fragment = scenario.getFragment() ?: return + scenario.onActivity { + fragment.showActivities(activities as List, nextcloudClient, -1) + fragment.setProgressIndicatorState(false) + } + val screenShotName = createName("${testClassName}_showActivities", "") + onView(isRoot()).check(matches(isDisplayed())) + scenario.onActivity { sut -> screenshotViaName(sut, screenShotName) } + } + } + + @Test + @ScreenshotTest + fun error() { + launchActivitiesActivity().use { scenario -> + val fragment = scenario.getFragment() ?: return + scenario.onActivity { + fragment.showEmptyContent("Error", "Error! Please try again later!") + fragment.setProgressIndicatorState(false) + } + val screenShotName = createName("${testClassName}_error", "") + onView(isRoot()).check(matches(isDisplayed())) + scenario.onActivity { sut -> screenshotViaName(sut, screenShotName) } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/AuthenticatorActivityIT.java b/app/src/androidTest/java/com/nextcloud/client/AuthenticatorActivityIT.java new file mode 100644 index 000000000000..4c704f0cae3e --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/AuthenticatorActivityIT.java @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client; + +import android.widget.TextView; + +import com.nextcloud.test.GrantStoragePermissionRule; +import com.owncloud.android.AbstractIT; +import com.owncloud.android.R; +import com.owncloud.android.authentication.AuthenticatorActivity; +import com.owncloud.android.utils.ScreenshotTest; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; + +import androidx.test.core.app.ActivityScenario; + + +public class AuthenticatorActivityIT extends AbstractIT { + private final String testClassName = "com.nextcloud.client.AuthenticatorActivityIT"; + + private static final String URL = "cloud.nextcloud.com"; + + @Rule + public final TestRule permissionRule = GrantStoragePermissionRule.grant(); + + @Test + @ScreenshotTest + public void login() { + try (ActivityScenario scenario = ActivityScenario.launch(AuthenticatorActivity.class)) { + scenario.onActivity(sut -> onIdleSync(() -> { + ((TextView) sut.findViewById(R.id.host_url_input)).setText(URL); + sut.runOnUiThread(() -> sut.getAccountSetupBinding().hostUrlInput.clearFocus()); + String screenShotName = createName(testClassName + "_" + "login", ""); + screenshotViaName(sut, screenShotName); + })); + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/CommunityFragmentIT.kt b/app/src/androidTest/java/com/nextcloud/client/CommunityFragmentIT.kt new file mode 100644 index 000000000000..269200b408d3 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/CommunityFragmentIT.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.GrantStoragePermissionRule.Companion.grant +import com.owncloud.android.AbstractIT +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.ui.navigation.NavigatorScreen +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule + +class CommunityFragmentIT : AbstractIT() { + private val testClassName = "com.nextcloud.client.CommunityFragmentIT" + + @get:Rule + var storagePermissionRules: TestRule = grant() + + @Test + @ScreenshotTest + fun open() { + val intent = NavigatorActivity.intent(targetContext, NavigatorScreen.Community) + ActivityScenario.launch(intent).use { scenario -> + val screenShotName = createName(testClassName + "_" + "open", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/EndToEndAction.java b/app/src/androidTest/java/com/nextcloud/client/EndToEndAction.java new file mode 100644 index 000000000000..08fab8c48669 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/EndToEndAction.java @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client; + +public enum EndToEndAction { + CREATE_FOLDER, + GO_INTO_FOLDER, + GO_UP, + UPLOAD_FILE, + DOWNLOAD_FILE, + DELETE_FILE, +} diff --git a/app/src/androidTest/java/com/nextcloud/client/FileDisplayActivityIT.kt b/app/src/androidTest/java/com/nextcloud/client/FileDisplayActivityIT.kt new file mode 100644 index 000000000000..0805ef65e365 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/FileDisplayActivityIT.kt @@ -0,0 +1,293 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.DrawerActions +import androidx.test.espresso.contrib.NavigationViewActions +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.nextcloud.test.RetryTestRule +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.R +import com.owncloud.android.lib.resources.files.CreateFolderRemoteOperation +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation +import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation +import com.owncloud.android.lib.resources.shares.CreateShareRemoteOperation +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.operations.CreateFolderOperation +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.adapter.OCFileListItemViewHolder +import com.owncloud.android.utils.EspressoIdlingResource +import org.hamcrest.Matchers.allOf +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class FileDisplayActivityIT : AbstractOnServerIT() { + + @Before + fun registerIdlingResource() { + IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource) + } + + @After + fun unregisterIdlingResource() { + IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource) + } + + @get:Rule + val retryRule = RetryTestRule() // showShares is flaky + + @Suppress("DEPRECATION") + @Test + fun showShares() { + assertTrue(ExistenceCheckRemoteOperation("/shareToAdmin/", true).execute(client).isSuccess) + assertTrue(CreateFolderRemoteOperation("/shareToAdmin/", true).execute(client).isSuccess) + assertTrue(CreateFolderRemoteOperation("/shareToGroup/", true).execute(client).isSuccess) + assertTrue(CreateFolderRemoteOperation("/shareViaLink/", true).execute(client).isSuccess) + assertTrue(CreateFolderRemoteOperation("/noShare/", true).execute(client).isSuccess) + + // share folder to user "admin" + assertTrue( + CreateShareRemoteOperation( + "/shareToAdmin/", + ShareType.USER, + "admin", + false, + "", + OCShare.MAXIMUM_PERMISSIONS_FOR_FOLDER + ).execute(client).isSuccess + ) + + // share folder via public link + assertTrue( + CreateShareRemoteOperation( + "/shareViaLink/", + ShareType.PUBLIC_LINK, + "", + true, + "", + OCShare.READ_PERMISSION_FLAG + ).execute(client).isSuccess + ) + + // share folder to group + assertTrue( + CreateShareRemoteOperation( + "/shareToGroup/", + ShareType.GROUP, + "users", + false, + "", + OCShare.NO_PERMISSION + ).execute(client).isSuccess + ) + + launchActivity().use { scenario -> + scenario.onActivity { sut -> + onIdleSync { + // open drawer + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()) + + // click "shared" + onView(withId(R.id.nav_view)) + .perform(NavigationViewActions.navigateTo(R.id.nav_shared)) + } + } + } + } + + @Suppress("DEPRECATION") + @Test + fun allFiles() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + onIdleSync { + EspressoIdlingResource.increment() + // given test folder + assertTrue( + CreateFolderOperation("/test/", user, targetContext, storageManager) + .execute(client) + .isSuccess + ) + + // navigate into it + val test = storageManager.getFileByPath("/test/") + sut.file = test + sut.startSyncFolderOperation(test, false) + assertEquals(storageManager.getFileByPath("/test/"), sut.currentDir) + EspressoIdlingResource.decrement() + + // open drawer + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()) + + // click "all files" + onView(withId(R.id.nav_view)) + .perform(NavigationViewActions.navigateTo(R.id.nav_all_files)) + + // then should be in root again + assertEquals(storageManager.getFileByPath("/"), sut.currentDir) + } + } + } + } + + private fun checkToolbarTitle(childFolder: String) { + onView(withId(R.id.appbar)).check( + matches( + hasDescendant( + withText(childFolder) + ) + ) + ) + } + + @Suppress("DEPRECATION") + @Test + fun browseFavoriteAndBack() { + EspressoIdlingResource.increment() + // Create folder structure + val topFolder = "folder1" + + CreateFolderOperation("/$topFolder/", user, targetContext, storageManager) + .execute(client) + ToggleFavoriteRemoteOperation(true, "/$topFolder/") + .execute(client) + EspressoIdlingResource.decrement() + + launchActivity().use { scenario -> + scenario.onActivity { sut -> + onIdleSync { + // navigate to favorites + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()) + onView(withId(R.id.nav_view)) + .perform(NavigationViewActions.navigateTo(R.id.nav_favorites)) + + // check sort button is not shown, favorites are not sortable + onView( + withId(R.id.sort_button) + ).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))) + + // browse into folder + onView(withId(R.id.list_root)) + .perform(closeSoftKeyboard()) + .perform( + RecyclerViewActions.actionOnItemAtPosition( + 0, + click() + ) + ) + checkToolbarTitle(topFolder) + // sort button should now be visible + onView(withId(R.id.sort_button)).check(matches(ViewMatchers.isDisplayed())) + + // browse back, should be back to All Files + Espresso.pressBack() + checkToolbarTitle(sut.getString(R.string.app_name)) + onView(withId(R.id.sort_button)).check(matches(ViewMatchers.isDisplayed())) + } + } + } + } + + @Suppress("DEPRECATION") + @Test + fun switchToGridView() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + onIdleSync { + assertTrue( + CreateFolderOperation("/test/", user, targetContext, storageManager) + .execute(client) + .isSuccess + ) + onView(withId(R.id.switch_grid_view_button)).perform(click()) + } + } + } + } + + @Test + fun openAccountSwitcher() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + onIdleSync { + onView(withId(R.id.switch_account_button)).perform(click()) + } + } + } + } + + @Test + fun testShowAndDismissLoadingDialog() { + launchActivity().use { scenario -> + val loadingText = "Some text displayed while loading" + + // Test that display works + scenario.onActivity { sut -> + sut.showLoadingDialog(loadingText) + } + onView(withText(loadingText)) + .check(matches(isDisplayed())) + + // Test that hiding works + scenario.onActivity { sut -> + sut.dismissLoadingDialog() + } + onView(allOf(withText(loadingText), isDisplayed())) + .check(doesNotExist()) + + // Test that there is no timing issue when hiding the dialog directly after. + // This timing issue was reproducible when testing RemoveFilesDialogFragment#removeFiles + // as well as sporadically "in the wild". + scenario.onActivity { sut -> + sut.showLoadingDialog(loadingText) + sut.dismissLoadingDialog() + } + onView(allOf(withText(loadingText), isDisplayed())) + .check(doesNotExist()) + // Wait for a potential timing issue - dialog appearing belatedly + Thread.sleep(1000) + onView(allOf(withText(loadingText), isDisplayed())) + .check(doesNotExist()) + + // Test that multiple display calls after another don't cause a timing issue + scenario.onActivity { sut -> + sut.showLoadingDialog(loadingText) + sut.showLoadingDialog(loadingText) + sut.showLoadingDialog(loadingText) + sut.dismissLoadingDialog() + } + onView(allOf(withText(loadingText), isDisplayed())) + .check(doesNotExist()) + // Wait for a potential timing issue - dialog appearing belatedly + Thread.sleep(1000) + onView(allOf(withText(loadingText), isDisplayed())) + .check(doesNotExist()) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/FileDisplayActivityScreenshotIT.kt b/app/src/androidTest/java/com/nextcloud/client/FileDisplayActivityScreenshotIT.kt new file mode 100644 index 000000000000..d727d1acd9aa --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/FileDisplayActivityScreenshotIT.kt @@ -0,0 +1,139 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client + +import android.Manifest +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.DrawerActions +import androidx.test.espresso.contrib.NavigationViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.rule.GrantPermissionRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.fragment.EmptyListState +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Assert +import org.junit.Rule +import org.junit.Test + +class FileDisplayActivityScreenshotIT : AbstractIT() { + private val testClassName = "com.nextcloud.client.FileDisplayActivityScreenshotIT" + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.POST_NOTIFICATIONS + ) + + companion object { + private const val TAG = "FileDisplayActivityScreenshotIT" + } + + @Test + @ScreenshotTest + fun open() { + try { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + sut.run { + listOfFilesFragment?.let { + it.setFabEnabled(false) + resetScrolling(true) + it.setEmptyListMessage(EmptyListState.LOADING) + it.isLoading = false + } + } + } + + val screenShotName = createName(testClassName + "_" + "open", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } catch (e: SecurityException) { + Log_OC.e(TAG, "Error caught at open $e") + } + } + + @Test + @ScreenshotTest + fun showMediaThenAllFiles() { + try { + launchActivity().use { scenario -> + var activity: FileDisplayActivity? = null + scenario.onActivity { sut -> + activity = sut + val fragment = sut.listOfFilesFragment + Assert.assertNotNull(fragment) + fragment!!.setFabEnabled(false) + fragment.setEmptyListMessage(EmptyListState.LOADING) + fragment.isLoading = false + } + + onView(ViewMatchers.withId(R.id.drawer_layout)).perform(DrawerActions.open()) + onView(ViewMatchers.withId(R.id.nav_view)) + .perform(NavigationViewActions.navigateTo(R.id.nav_gallery)) + onView(ViewMatchers.withId(R.id.drawer_layout)).perform(DrawerActions.open()) + onView(ViewMatchers.withId(R.id.nav_view)) + .perform(NavigationViewActions.navigateTo(R.id.nav_all_files)) + + val fragment = activity!!.listOfFilesFragment + fragment!!.setFabEnabled(false) + fragment.setEmptyListMessage(EmptyListState.LOADING) + fragment.isLoading = false + + val screenShotName = createName(testClassName + "_" + "showMediaThenAllFiles", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } catch (e: SecurityException) { + Log_OC.e(TAG, "Error caught at open $e") + } + } + + @Test + @ScreenshotTest + fun drawer() { + try { + launchActivity().use { scenario -> + onView(ViewMatchers.withId(R.id.drawer_layout)).perform(DrawerActions.open()) + + scenario.onActivity { sut -> + sut.run { + hideInfoBox() + resetScrolling(true) + + listOfFilesFragment?.let { + it.setFabEnabled(false) + it.setEmptyListMessage(EmptyListState.LOADING) + it.isLoading = false + } + } + } + + val screenShotName = createName(testClassName + "_" + "drawer", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } catch (e: SecurityException) { + Log_OC.e(TAG, "Error caught at open $e") + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/FirstRunActivityIT.kt b/app/src/androidTest/java/com/nextcloud/client/FirstRunActivityIT.kt new file mode 100644 index 000000000000..1c4e58d9084e --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/FirstRunActivityIT.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.client.onboarding.FirstRunActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class FirstRunActivityIT : AbstractIT() { + private val testClassName = "com.nextcloud.client.FirstRunActivityIT" + + @Test + @ScreenshotTest + fun open() { + launchActivity().use { scenario -> + val screenShotName = createName(testClassName + "_" + "open", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/SettingsActivityIT.kt b/app/src/androidTest/java/com/nextcloud/client/SettingsActivityIT.kt new file mode 100644 index 000000000000..9b9d283af3f6 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/SettingsActivityIT.kt @@ -0,0 +1,89 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client + +import android.content.Intent +import android.os.Looper +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.GrantStoragePermissionRule.Companion.grant +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.ui.activity.RequestCredentialsActivity +import com.owncloud.android.ui.activity.SettingsActivity +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule + +@Suppress("FunctionNaming") +class SettingsActivityIT : AbstractIT() { + private val testClassName = "com.nextcloud.client.SettingsActivityIT" + + @get:Rule + var storagePermissionRule: TestRule = grant() + + @Test + @ScreenshotTest + fun open() { + launchActivity().use { scenario -> + val screenShotName = createName(testClassName + "_" + "open", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun showMnemonic_Error() { + launchActivity().use { scenario -> + val screenShotName = createName(testClassName + "_" + "showMnemonic_Error", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + sut.handleMnemonicRequest(null) + screenshotViaName(sut, screenShotName) + } + } + } + + @Suppress("DEPRECATION") + @Test + fun showMnemonic() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + val intent = Intent().apply { + putExtra(RequestCredentialsActivity.KEY_CHECK_RESULT, RequestCredentialsActivity.KEY_CHECK_RESULT_TRUE) + } + + ArbitraryDataProviderImpl(targetContext).run { + storeOrUpdateKeyValue(user.accountName, EncryptionUtils.MNEMONIC, "Secret mnemonic") + } + + launchActivity().use { scenario -> + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + sut.handleMnemonicRequest(intent) + } + + Looper.myLooper()?.quitSafely() + Assert.assertTrue(true) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/SyncedFoldersActivityIT.kt b/app/src/androidTest/java/com/nextcloud/client/SyncedFoldersActivityIT.kt new file mode 100644 index 000000000000..6ae0157b6706 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/SyncedFoldersActivityIT.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client + +import android.content.Intent +import android.os.Looper +import androidx.appcompat.app.AlertDialog +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.client.preferences.SubFolderRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.SyncedFolderDisplayItem +import com.owncloud.android.ui.activity.SyncedFoldersActivity +import com.owncloud.android.ui.dialog.SyncedFolderPreferencesDialogFragment.Companion.newInstance +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class SyncedFoldersActivityIT : AbstractIT() { + private val testClassName = "com.nextcloud.client.SyncedFoldersActivityIT" + + @Test + @ScreenshotTest + fun open() { + launchActivity().use { scenario -> + val screenShotName = createName(testClassName + "_" + "open", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + sut.adapter.clear() + screenshotViaName(sut.binding.emptyList.emptyListView, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun testSyncedFolderDialog() { + val item = SyncedFolderDisplayItem( + 1, + "/sdcard/DCIM/", + "/InstantUpload/", + true, + false, + false, + true, + "test@https://nextcloud.localhost", + 0, + 0, + true, + 1000, + "Name", + MediaFolderType.IMAGE, + false, + SubFolderRule.YEAR_MONTH, + false, + SyncedFolder.NOT_SCANNED_YET + ) + + val intent = Intent(targetContext, SyncedFoldersActivity::class.java) + launchActivity(intent).use { scenario -> + val screenShotName = createName(testClassName + "_" + "testSyncedFolderDialog", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + val fragment = newInstance(item, 0) + fragment!!.show(sut.supportFragmentManager, "") + screenshot(fragment.requireDialog().window?.decorView, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun showPowerCheckDialog() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + + val intent = Intent(targetContext, SyncedFoldersActivity::class.java) + + launchActivity(intent).use { scenario -> + var dialog: AlertDialog? = null + scenario.onActivity { sut -> + dialog = sut.buildPowerCheckDialog() + sut.showPowerCheckDialog() + } + + val screenShotName = createName(testClassName + "_" + "showPowerCheckDialog", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshot(dialog!!.window?.decorView, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/TestRunner.kt b/app/src/androidTest/java/com/nextcloud/client/TestRunner.kt new file mode 100644 index 000000000000..d5e0227cb6a2 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/TestRunner.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import com.nextcloud.test.TestMainApp + +class TestRunner : AndroidJUnitRunner() { + @Throws(ClassNotFoundException::class, IllegalAccessException::class, InstantiationException::class) + override fun newApplication(cl: ClassLoader, className: String, context: Context): Application = + newApplication(TestMainApp::class.java, context) +} diff --git a/app/src/androidTest/java/com/nextcloud/client/UploadListActivityActivityIT.kt b/app/src/androidTest/java/com/nextcloud/client/UploadListActivityActivityIT.kt new file mode 100644 index 000000000000..64fb9cd3632c --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/UploadListActivityActivityIT.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.DrawerActions +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.ui.activity.UploadListActivity +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class UploadListActivityActivityIT : AbstractIT() { + private val testClassName = "com.nextcloud.client.UploadListActivityActivityIT" + + @Test + @ScreenshotTest + fun openDrawer() { + launchActivity().use { scenario -> + onView(isRoot()).check(matches(isDisplayed())) + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()) + + val screenShotName = createName(testClassName + "_" + "openDrawer", "") + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/account/AnonymousUserTest.kt b/app/src/androidTest/java/com/nextcloud/client/account/AnonymousUserTest.kt new file mode 100644 index 000000000000..7def8527bf7a --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/account/AnonymousUserTest.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account + +import android.os.Parcel +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AnonymousUserTest { + @Test + fun anonymousUserImplementsParcelable() { + // GIVEN + // anonymous user instance + val original = AnonymousUser("test_account") + + // WHEN + // instance is serialized into Parcel + // instance is retrieved from Parcel + val parcel = Parcel.obtain() + parcel.setDataPosition(0) + parcel.writeParcelable(original, 0) + parcel.setDataPosition(0) + val retrieved = parcel.readParcelable(User::class.java.classLoader) + + // THEN + // retrieved instance in distinct + // instances are equal + Assert.assertNotSame(original, retrieved) + Assert.assertTrue(retrieved is AnonymousUser) + Assert.assertEquals(original, retrieved) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt b/app/src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt new file mode 100644 index 000000000000..f45afff06fbe --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/account/MockUserTest.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account + +import android.os.Parcel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class MockUserTest { + + private companion object { + const val ACCOUNT_NAME = "test_account_name" + const val ACCOUNT_TYPE = "test_account_type" + } + + @Test + fun mock_user_is_parcelable() { + // GIVEN + // mock user instance + val original = MockUser(ACCOUNT_NAME, ACCOUNT_TYPE) + + // WHEN + // instance is serialized into Parcel + // instance is retrieved from Parcel + val parcel = Parcel.obtain() + parcel.setDataPosition(0) + parcel.writeParcelable(original, 0) + parcel.setDataPosition(0) + val retrieved = parcel.readParcelable(User::class.java.classLoader) + + // THEN + // retrieved instance in distinct + // instances are equal + assertNotSame(original, retrieved) + assertTrue(retrieved is MockUser) + assertEquals(original, retrieved) + } + + @Test + fun mock_user_has_platform_account() { + // GIVEN + // mock user instance + val mock = MockUser(ACCOUNT_NAME, ACCOUNT_TYPE) + + // THEN + // can convert to platform account + val account = mock.toPlatformAccount() + assertEquals(ACCOUNT_NAME, account.name) + assertEquals(ACCOUNT_TYPE, account.type) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/account/OwnCloudClientManagerTest.java b/app/src/androidTest/java/com/nextcloud/client/account/OwnCloudClientManagerTest.java new file mode 100644 index 000000000000..1c832c121934 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/account/OwnCloudClientManagerTest.java @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.net.Uri; +import android.os.Bundle; + +import com.owncloud.android.AbstractOnServerIT; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManager; +import com.owncloud.android.lib.common.accounts.AccountUtils; + +import org.junit.Test; + +import java.io.IOException; + +import androidx.test.platform.app.InstrumentationRegistry; + +import static org.junit.Assert.assertEquals; + +public class OwnCloudClientManagerTest extends AbstractOnServerIT { + + /** + * Like on files app we create & store an account in Android's account manager. + */ + @Test + public void testUserId() throws OperationCanceledException, AuthenticatorException, IOException, + AccountUtils.AccountNotFoundException { + Bundle arguments = InstrumentationRegistry.getArguments(); + + Uri url = Uri.parse(arguments.getString("TEST_SERVER_URL")); + String loginName = arguments.getString("TEST_SERVER_USERNAME"); + String password = arguments.getString("TEST_SERVER_PASSWORD"); + + AccountManager accountManager = AccountManager.get(targetContext); + String accountName = AccountUtils.buildAccountName(url, loginName); + Account newAccount = new Account(accountName, "nextcloud"); + + accountManager.addAccountExplicitly(newAccount, password, null); + accountManager.setUserData(newAccount, AccountUtils.Constants.KEY_OC_BASE_URL, url.toString()); + accountManager.setUserData(newAccount, AccountUtils.Constants.KEY_USER_ID, loginName); + + OwnCloudClientManager manager = new OwnCloudClientManager(); + OwnCloudAccount account = new OwnCloudAccount(newAccount, targetContext); + + OwnCloudClient client = manager.getClientFor(account, targetContext); + + assertEquals(loginName, client.getUserId()); + + accountManager.removeAccountExplicitly(newAccount); + + assertEquals(1, accountManager.getAccounts().length); + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/account/RegisteredUserTest.kt b/app/src/androidTest/java/com/nextcloud/client/account/RegisteredUserTest.kt new file mode 100644 index 000000000000..d429c4a641d8 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/account/RegisteredUserTest.kt @@ -0,0 +1,106 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account + +import android.accounts.Account +import android.net.Uri +import android.os.Parcel +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudBasicCredentials +import com.owncloud.android.lib.resources.status.NextcloudVersion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.net.URI + +class RegisteredUserTest { + + private companion object { + fun buildTestUser(accountName: String): RegisteredUser { + val uri = Uri.parse("https://nextcloud.localhost") + val credentials = OwnCloudBasicCredentials("user", "pass") + val account = Account(accountName, "test-type") + val ownCloudAccount = OwnCloudAccount(uri, credentials) + val server = Server( + uri = URI(uri.toString()), + version = NextcloudVersion.nextcloud_31 + ) + return RegisteredUser( + account = account, + ownCloudAccount = ownCloudAccount, + server = server + ) + } + } + + private lateinit var user: RegisteredUser + + @Before + fun setUp() { + user = buildTestUser("test@nextcloud.localhost") + } + + @Test + fun registeredUserImplementsParcelable() { + // GIVEN + // registered user instance + + // WHEN + // instance is serialized into Parcel + // instance is retrieved from Parcel + val parcel = Parcel.obtain() + parcel.setDataPosition(0) + parcel.writeParcelable(user, 0) + parcel.setDataPosition(0) + val deserialized = parcel.readParcelable(User::class.java.classLoader) + + // THEN + // retrieved instance in distinct + // instances are equal + assertNotSame(user, deserialized) + assertTrue(deserialized is RegisteredUser) + assertEquals(user, deserialized) + } + + @Test + fun accountNamesEquality() { + // GIVEN + // registered user instance with lower-case account name + // registered user instance with mixed-case account name + val user1 = buildTestUser("account_name") + val user2 = buildTestUser("Account_Name") + + // WHEN + // account names are checked for equality + val equal = user1.nameEquals(user2) + + // THEN + // account names are equal + assertTrue(equal) + } + + @Test + fun accountNamesEqualityCheckIsNullSafe() { + // GIVEN + // registered user instance with lower-case account name + // null account + val user1 = buildTestUser("account_name") + val user2: User? = null + + // WHEN + // account names are checked for equality against null + val equal = user1.nameEquals(user2) + + // THEN + // account names are not equal + assertFalse(equal) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/account/UserAccountManagerImplTest.java b/app/src/androidTest/java/com/nextcloud/client/account/UserAccountManagerImplTest.java new file mode 100644 index 000000000000..053759d18acb --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/account/UserAccountManagerImplTest.java @@ -0,0 +1,79 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019-2023 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.os.Bundle; + +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.owncloud.android.AbstractOnServerIT; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.accounts.AccountUtils; + +import org.junit.Before; +import org.junit.Test; + +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertNull; +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertFalse; + +public class UserAccountManagerImplTest extends AbstractOnServerIT { + + private AccountManager accountManager; + + @Before + public void setUp() { + accountManager = AccountManager.get(targetContext); + } + + @Test + public void updateOneAccount() { + AppPreferences appPreferences = AppPreferencesImpl.fromContext(targetContext); + UserAccountManagerImpl sut = new UserAccountManagerImpl(targetContext, accountManager); + assertEquals(1, sut.getAccounts().length); + assertFalse(appPreferences.isUserIdMigrated()); + + Account account = sut.getAccounts()[0]; + + // for testing remove userId + accountManager.setUserData(account, AccountUtils.Constants.KEY_USER_ID, null); + assertNull(accountManager.getUserData(account, AccountUtils.Constants.KEY_USER_ID)); + + boolean success = sut.migrateUserId(); + assertTrue(success); + + Bundle arguments = androidx.test.platform.app.InstrumentationRegistry.getArguments(); + String userId = arguments.getString("TEST_SERVER_USERNAME"); + + // assume that userId == loginname (as we manually set it) + assertEquals(userId, accountManager.getUserData(account, AccountUtils.Constants.KEY_USER_ID)); + } + + @Test + public void checkName() { + UserAccountManagerImpl sut = new UserAccountManagerImpl(targetContext, accountManager); + + Account owner = new Account("John@nextcloud.local", "nextcloud"); + Account account1 = new Account("John@nextcloud.local", "nextcloud"); + Account account2 = new Account("john@nextcloud.local", "nextcloud"); + + OCFile file1 = new OCFile("/test1.pdf"); + file1.setOwnerId("John"); + + assertTrue(sut.accountOwnsFile(file1, owner)); + assertTrue(sut.accountOwnsFile(file1, account1)); + assertTrue(sut.accountOwnsFile(file1, account2)); + + file1.setOwnerId("john"); + assertTrue(sut.accountOwnsFile(file1, owner)); + assertTrue(sut.accountOwnsFile(file1, account1)); + assertTrue(sut.accountOwnsFile(file1, account2)); + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt b/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt new file mode 100644 index 000000000000..d2d333f4797f --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.assistant + +import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositoryImpl +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.status.NextcloudVersion +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@Suppress("MagicNumber") +class AssistantRepositoryTests : AbstractOnServerIT() { + + private var sut: AssistantRemoteRepositoryImpl? = null + + @Before + fun setup() { + sut = AssistantRemoteRepositoryImpl(nextcloudClient, capability) + } + + @Test + fun testGetTaskTypes() { + testOnlyOnServer(NextcloudVersion.nextcloud_28) + + if (capability.assistant.isFalse) { + return + } + + runBlocking { + val result = sut?.fetchTaskTypes() + assertTrue(result?.isNotEmpty() == true) + } + } + + @Test + fun testGetTaskList() { + testOnlyOnServer(NextcloudVersion.nextcloud_28) + + if (capability.assistant.isFalse) { + return + } + + runBlocking { + val result = sut?.getTaskList("assistant") + assertTrue(result?.isEmpty() == true || (result?.size ?: 0) > 0) + } + } + + @Test + fun testCreateTask() { + testOnlyOnServer(NextcloudVersion.nextcloud_28) + + if (capability.assistant.isFalse) { + return + } + + val input = "Give me some random output for test purpose" + val taskType = TaskTypeData( + "core:text2text", + "Free text to text prompt", + "Runs an arbitrary prompt through a language model that returns a reply", + emptyMap(), + emptyMap() + ) + + runBlocking { + val result = sut?.createTask(input, taskType) + assertTrue(result?.isSuccess == true) + } + } + + @Test + fun testDeleteTask() { + testOnlyOnServer(NextcloudVersion.nextcloud_28) + + if (capability.assistant.isFalse) { + return + } + + testCreateTask() + + sleep(120) + + runBlocking { + val taskList = sut?.getTaskList("assistant") + assertTrue(taskList != null) + + sleep(120) + + assert((taskList?.size ?: 0) > 0) + + val result = sut?.deleteTask(taskList!!.first().id) + assertTrue(result?.isSuccess == true) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/database/migrations/MigrationTest.kt b/app/src/androidTest/java/com/nextcloud/client/database/migrations/MigrationTest.kt new file mode 100644 index 000000000000..00e96dc84ca9 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/database/migrations/MigrationTest.kt @@ -0,0 +1,82 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.migrations + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.nextcloud.client.database.NextcloudDatabase +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + NextcloudDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + @Throws(IOException::class) + fun migrate67to68() { + val nullId = 6 + val notNullId = 7 + val notNullLocalIdValue = 1234 + + var db = helper.createDatabase(TEST_DB, 67) + + // create some data + db.apply { + execSQL( + "INSERT INTO filelist VALUES($nullId,'foo.zip','foo.zip','/foo.zip','/foo.zip',1,1643648081," + + "1643648081000,'application/zip',178382355,NULL,'test@nextcloud',1674554955638,0,0,''," + + "'f45028679b68652c6b345b5d8c9a5d63',0,'RGDNVW','00014889ocb5tqw7y2f3',NULL,0,0,0,0,NULL,0,NULL," + + "0,0,'test','test','','[]',NULL,'null',0,-1,NULL,NULL,NULL,0,0,NULL);" + ) + execSQL( + "INSERT INTO filelist VALUES($notNullId,'foo.zip','foo.zip','/foo.zip','/foo.zip',1,1643648081," + + "1643648081000,'application/zip',178382355,NULL,'test@nextcloud',1674554955638,0,0,''," + + "'f45028679b68652c6b345b5d8c9a5d63',0,'RGDNVW','00014889ocb5tqw7y2f3',NULL,0,0,0,0,NULL,0,NULL," + + "0,0,'test','test','','[]',NULL,'null',0,-1,NULL,NULL,NULL,0,0,NULL);" + ) + execSQL("UPDATE filelist SET local_id = NULL WHERE _id = $nullId") + execSQL("UPDATE filelist SET local_id = $notNullLocalIdValue WHERE _id = $notNullId") + + close() + } + + // run migration and validate schema matches + db = helper.runMigrationsAndValidate(TEST_DB, 68, true, Migration67to68()) + + // check values are correct + db.query("SELECT local_id FROM filelist WHERE _id=$nullId").use { cursor -> + cursor.moveToFirst() + val localId = cursor.getInt(cursor.getColumnIndex("local_id")) + assertEquals("NULL localId is not -1 after migration", -1, localId) + } + + db.query("SELECT local_id FROM filelist WHERE _id=$notNullId").use { cursor -> + cursor.moveToFirst() + val localId = cursor.getInt(cursor.getColumnIndex("local_id")) + assertEquals("Not null localId is not the same after migration", notNullLocalIdValue, localId) + } + + db.close() + } + + companion object { + private const val TEST_DB = "migration-test" + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/documentscan/GeneratePDFUseCaseTest.kt b/app/src/androidTest/java/com/nextcloud/client/documentscan/GeneratePDFUseCaseTest.kt new file mode 100644 index 000000000000..36402fb52d5f --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/documentscan/GeneratePDFUseCaseTest.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.documentscan + +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import com.nextcloud.client.logger.Logger +import com.owncloud.android.AbstractIT +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +internal class GeneratePDFUseCaseTest : AbstractIT() { + + @MockK + private lateinit var logger: Logger + + private lateinit var sut: GeneratePDFUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + sut = GeneratePDFUseCase(logger) + } + + @Test + fun invalidArguments_shouldReturnFalse() { + var result = sut.execute(emptyList(), "/test/foo.pdf") + assertFalse("Usecase does not indicate failure with invalid arguments", result) + result = sut.execute(listOf("/test.jpg"), "") + assertFalse("Usecase does not indicate failure with invalid arguments", result) + } + + @Test + fun generatePdf_checkPages() { + // can't think of how to test the _content_ of the pages + val images = listOf( + getFile("image.jpg"), + getFile("christine.jpg") + ).map { it.path } + + val output = "/sdcard/test.pdf" + + val result = sut.execute(images, output) + + assertTrue("Usecase does not indicate success", result) + + val outputFile = File(output) + + assertTrue("Output file does not exist", outputFile.exists()) + + ParcelFileDescriptor.open(outputFile, ParcelFileDescriptor.MODE_READ_ONLY).use { + PdfRenderer(it).use { renderer -> + val pageCount = renderer.pageCount + assertTrue("Page count is not correct", pageCount == 2) + } + } + + // clean up + outputFile.delete() + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/etm/EtmActivityTest.kt b/app/src/androidTest/java/com/nextcloud/client/etm/EtmActivityTest.kt new file mode 100644 index 000000000000..b81ce3777c24 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/etm/EtmActivityTest.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.owncloud.android.AbstractIT +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class EtmActivityTest : AbstractIT() { + private val testClassName = "com.nextcloud.client.etm.EtmActivityTest" + + @Test + @ScreenshotTest + fun overview() { + launchActivity().use { scenario -> + val screenShotName = createName(testClassName + "_" + "overview", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun accounts() { + launchActivity().use { scenario -> + val screenShotName = createName(testClassName + "_" + "accounts", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + sut.vm.onPageSelected(1) + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/files/DeepLinkHandlerTest.kt b/app/src/androidTest/java/com/nextcloud/client/files/DeepLinkHandlerTest.kt new file mode 100644 index 000000000000..f6a71154ba4f --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/files/DeepLinkHandlerTest.kt @@ -0,0 +1,222 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files + +import android.net.Uri +import com.nextcloud.client.account.Server +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.lib.resources.status.OwnCloudVersion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Suite +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.net.URI + +@RunWith(Suite::class) +@Suite.SuiteClasses( + DeepLinkHandlerTest.DeepLinkPattern::class, + DeepLinkHandlerTest.FileDeepLink::class +) +class DeepLinkHandlerTest { + + @RunWith(Parameterized::class) + class DeepLinkPattern { + + companion object { + private const val FILE_ID = 1234 + private val SERVER_BASE_URLS = listOf( + "http://hostname.net", + "https://hostname.net", + "http://hostname.net/subdir1", + "https://hostname.net/subdir1", + "http://hostname.net/subdir1/subdir2", + "https://hostname.net/subdir1/subdir2", + "http://hostname.net/subdir1/subdir2/subdir3", + "https://hostname.net/subdir1/subdir2/subdir3" + ) + private val INDEX_PHP_PATH = listOf( + "", + "/index.php" + ) + + @Parameterized.Parameters + @JvmStatic + fun urls(): Array> { + val testInput = mutableListOf>() + SERVER_BASE_URLS.forEach { baseUrl -> + INDEX_PHP_PATH.forEach { indexPath -> + val url = "$baseUrl$indexPath/f/$FILE_ID" + testInput.add(arrayOf(baseUrl, indexPath, "$FILE_ID", url)) + } + } + return testInput.toTypedArray() + } + } + + @Parameterized.Parameter(0) + lateinit var baseUrl: String + + @Parameterized.Parameter(1) + lateinit var indexPath: String + + @Parameterized.Parameter(2) + lateinit var fileId: String + + @Parameterized.Parameter(3) + lateinit var url: String + + @Test + fun matches_deep_link_patterns() { + val match = DeepLinkHandler.DEEP_LINK_PATTERN.matchEntire(url) + assertNotNull("Url [$url] does not match pattern", match) + assertEquals(baseUrl, match?.groupValues?.get(DeepLinkHandler.BASE_URL_GROUP_INDEX)) + assertEquals(indexPath, match?.groupValues?.get(DeepLinkHandler.INDEX_PATH_GROUP_INDEX)) + assertEquals(fileId, match?.groupValues?.get(DeepLinkHandler.FILE_ID_GROUP_INDEX)) + } + + @Test + fun no_trailing_path_allowed_after_file_id() { + val invalidUrl = "$url/" + val match = DeepLinkHandler.DEEP_LINK_PATTERN.matchEntire(invalidUrl) + assertNull(match) + } + } + + class FileDeepLink { + + companion object { + const val OTHER_SERVER_BASE_URL = "https://someotherserver.net" + const val SERVER_BASE_URL = "https://server.net" + const val FILE_ID = "1234567890" + val DEEP_LINK: Uri = Uri.parse("$SERVER_BASE_URL/index.php/f/$FILE_ID") + + fun createMockUser(serverBaseUrl: String): User { + val user = mock() + val uri = URI.create(serverBaseUrl) + val server = Server(uri = uri, version = OwnCloudVersion.nextcloud_20) + whenever(user.server).thenReturn(server) + return user + } + } + + @Mock + lateinit var userAccountManager: UserAccountManager + private lateinit var allUsers: List + private lateinit var handler: DeepLinkHandler + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + whenever(userAccountManager.allUsers).thenAnswer { allUsers } + allUsers = emptyList() + handler = DeepLinkHandler(userAccountManager) + } + + @Test + fun no_user_can_open_file() { + // GIVEN + // no user capable of opening the file + allUsers = listOf( + createMockUser(OTHER_SERVER_BASE_URL), + createMockUser(OTHER_SERVER_BASE_URL) + ) + + // WHEN + // deep link is parsed + val match = handler.parseDeepLink(DEEP_LINK) + + // THEN + // link is valid + // no user can open the file + assertNotNull(match) + assertEquals(0, match?.users?.size) + } + + @Test + fun single_user_can_open_file() { + // GIVEN + // multiple users registered + // one user capable of opening the link + val matchingUser = createMockUser(SERVER_BASE_URL) + allUsers = listOf( + createMockUser(OTHER_SERVER_BASE_URL), + matchingUser, + createMockUser(OTHER_SERVER_BASE_URL) + ) + + // WHEN + // deep link is parsed + val match = handler.parseDeepLink(DEEP_LINK) + + // THEN + // link can be opened by single user + assertNotNull(match) + assertSame(matchingUser, match?.users?.get(0)) + } + + @Test + fun multiple_users_can_open_file() { + // GIVEN + // mutltiple users registered + // multiple users capable of opening the link + val matchingUsers = setOf( + createMockUser(SERVER_BASE_URL), + createMockUser(SERVER_BASE_URL) + ) + val otherUsers = setOf( + createMockUser(OTHER_SERVER_BASE_URL), + createMockUser(OTHER_SERVER_BASE_URL) + ) + allUsers = listOf(matchingUsers, otherUsers).flatten() + + // WHEN + // deep link is parsed + val match = handler.parseDeepLink(DEEP_LINK) + + // THEN + // link can be opened by multiple matching users + assertNotNull(match) + assertEquals(matchingUsers, match?.users?.toSet()) + } + + @Test + fun match_contains_extracted_file_id() { + // WHEN + // valid deep file link is parsed + val match = handler.parseDeepLink(DEEP_LINK) + + // THEN + // file id is returned + assertEquals(FILE_ID, match?.fileId) + } + + @Test + fun no_match_for_invalid_link() { + // GIVEN + // invalid deep link + val invalidLink = Uri.parse("http://www.dodgylink.com/index.php") + + // WHEN + // deep link is parsed + val match = handler.parseDeepLink(invalidLink) + + // THEN + // no match + assertNull(match) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/files/download/DownloaderServiceTest.kt b/app/src/androidTest/java/com/nextcloud/client/files/download/DownloaderServiceTest.kt new file mode 100644 index 000000000000..d3434871ae9d --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/files/download/DownloaderServiceTest.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files.download + +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.rule.ServiceTestRule +import com.nextcloud.client.account.MockUser +import com.nextcloud.client.jobs.transfer.FileTransferService +import io.mockk.MockKAnnotations +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +class DownloaderServiceTest { + + @get:Rule + val service = ServiceTestRule.withTimeout(3, TimeUnit.SECONDS) + + val user = MockUser() + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + } + + @Test(expected = TimeoutException::class) + fun cannot_bind_to_service_without_user() { + val intent = FileTransferService.createBindIntent(getApplicationContext(), user) + intent.removeExtra(FileTransferService.EXTRA_USER) + service.bindService(intent) + } + + @Test + fun bind_with_user() { + val intent = FileTransferService.createBindIntent(getApplicationContext(), user) + val binder = service.bindService(intent) + assertTrue(binder is FileTransferService.Binder) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/files/download/RegistryTest.kt b/app/src/androidTest/java/com/nextcloud/client/files/download/RegistryTest.kt new file mode 100644 index 000000000000..2bf751757468 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/files/download/RegistryTest.kt @@ -0,0 +1,514 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files.download + +import com.nextcloud.client.account.User +import com.nextcloud.client.files.DownloadRequest +import com.nextcloud.client.files.Registry +import com.nextcloud.client.files.Request +import com.nextcloud.client.jobs.transfer.Transfer +import com.nextcloud.client.jobs.transfer.TransferState +import com.owncloud.android.datamodel.OCFile +import io.mockk.CapturingSlot +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Suite +import java.util.UUID + +@RunWith(Suite::class) +@Suite.SuiteClasses( + RegistryTest.Pending::class, + RegistryTest.Start::class, + RegistryTest.Progress::class, + RegistryTest.Complete::class, + RegistryTest.GetTransfers::class, + RegistryTest.IsRunning::class +) +class RegistryTest { + + abstract class Base { + companion object { + const val MAX_TRANSFER_THREADS = 4 + const val PROGRESS_FULL = 100 + const val PROGRESS_HALF = 50 + } + + @MockK + lateinit var user: User + + lateinit var file: OCFile + + @MockK + lateinit var onTransferStart: (UUID, Request) -> Unit + + @MockK + lateinit var onTransferChanged: (Transfer) -> Unit + + internal lateinit var registry: Registry + + @Before + fun setUpBase() { + MockKAnnotations.init(this, relaxed = true) + file = OCFile("/test/path") + registry = Registry(onTransferStart, onTransferChanged, MAX_TRANSFER_THREADS) + resetMocks() + } + + fun resetMocks() { + clearAllMocks() + every { onTransferStart(any(), any()) } answers {} + every { onTransferChanged(any()) } answers {} + } + } + + class Pending : Base() { + + @Test + fun inserting_pending_transfer() { + // GIVEN + // registry has no pending transfers + assertEquals(0, registry.pending.size) + + // WHEN + // new transfer requests added + val addedTransfersCount = 10 + for (i in 0 until addedTransfersCount) { + val request = DownloadRequest(user, file) + registry.add(request) + } + + // THEN + // transfer is added to the pending queue + assertEquals(addedTransfersCount, registry.pending.size) + } + } + + class Start : Base() { + + companion object { + const val ENQUEUED_REQUESTS_COUNT = 10 + } + + @Before + fun setUp() { + for (i in 0 until ENQUEUED_REQUESTS_COUNT) { + registry.add(DownloadRequest(user, file)) + } + assertEquals(ENQUEUED_REQUESTS_COUNT, registry.pending.size) + } + + @Test + fun starting_transfer() { + // WHEN + // started + registry.startNext() + + // THEN + // up to max threads requests are started + // start callback is triggered + // update callback is triggered on transfer transition + // started transfers are in running state + assertEquals( + "Transfers not moved to running queue", + MAX_TRANSFER_THREADS, + registry.running.size + ) + assertEquals( + "Transfers not moved from pending queue", + ENQUEUED_REQUESTS_COUNT - MAX_TRANSFER_THREADS, + registry.pending.size + ) + verify(exactly = MAX_TRANSFER_THREADS) { onTransferStart(any(), any()) } + val startedTransfers = mutableListOf() + verify(exactly = MAX_TRANSFER_THREADS) { onTransferChanged(capture(startedTransfers)) } + assertEquals( + "Callbacks not invoked for running transfers", + MAX_TRANSFER_THREADS, + startedTransfers.size + ) + startedTransfers.forEach { + assertEquals("Transfer not placed into running state", TransferState.RUNNING, it.state) + } + } + + @Test + fun start_is_ignored_if_no_more_free_threads() { + // WHEN + // max number of running transfers + registry.startNext() + assertEquals(MAX_TRANSFER_THREADS, registry.running.size) + clearAllMocks() + + // WHEN + // starting more transfers + registry.startNext() + + // THEN + // no more transfers can be started + assertEquals(MAX_TRANSFER_THREADS, registry.running.size) + verify(exactly = 0) { onTransferStart(any(), any()) } + } + } + + class Progress : Base() { + + var uuid: UUID = UUID.randomUUID() + + @Before + fun setUp() { + val request = DownloadRequest(user, file) + uuid = registry.add(request) + registry.startNext() + assertEquals(uuid, request.uuid) + assertEquals(1, registry.running.size) + resetMocks() + } + + @Test + fun transfer_progress_is_updated() { + // GIVEN + // a transfer is running + + // WHEN + // transfer progress is updated + val progressHalf = 50 + registry.progress(uuid, progressHalf) + + // THEN + // progress is updated + // update callback is invoked + val transfer = mutableListOf() + verify { onTransferChanged(capture(transfer)) } + assertEquals(1, transfer.size) + assertEquals(progressHalf, transfer.first().progress) + } + + @Test + fun updates_for_non_running_transfers_are_ignored() { + // GIVEN + // transfer is not running + registry.complete(uuid, true) + assertEquals(0, registry.running.size) + resetMocks() + + // WHEN + // progress for a non-running transfer is updated + registry.progress(uuid, PROGRESS_HALF) + + // THEN + // progress update is ignored + verify(exactly = 0) { onTransferChanged(any()) } + } + + @Test + fun updates_for_non_existing_transfers_are_ignored() { + // GIVEN + // some transfer is running + + // WHEN + // progress is updated for non-existing transfer + val nonExistingTransferId = UUID.randomUUID() + registry.progress(nonExistingTransferId, PROGRESS_HALF) + + // THEN + // progress uppdate is ignored + verify(exactly = 0) { onTransferChanged(any()) } + } + } + + class Complete : Base() { + + lateinit var uuid: UUID + + @Before + fun setUp() { + uuid = registry.add(DownloadRequest(user, file)) + registry.startNext() + registry.progress(uuid, PROGRESS_FULL) + resetMocks() + } + + @Test + fun complete_successful_transfer_with_updated_file() { + // GIVEN + // a transfer is running + + // WHEN + // transfer is completed + // file has been updated + val updatedFile = OCFile("/updated/file") + registry.complete(uuid, true, updatedFile) + + // THEN + // transfer is completed successfully + // status carries updated file + val slot = CapturingSlot() + verify { onTransferChanged(capture(slot)) } + assertEquals(TransferState.COMPLETED, slot.captured.state) + assertSame(slot.captured.file, updatedFile) + } + + @Test + fun complete_successful_transfer() { + // GIVEN + // a transfer is running + + // WHEN + // transfer is completed + // file is not updated + registry.complete(uuid = uuid, success = true, file = null) + + // THEN + // transfer is completed successfully + // status carries previous file + val slot = CapturingSlot() + verify { onTransferChanged(capture(slot)) } + assertEquals(TransferState.COMPLETED, slot.captured.state) + assertSame(slot.captured.file, file) + } + + @Test + fun complete_failed_transfer() { + // GIVEN + // a transfer is running + + // WHEN + // transfer is failed + registry.complete(uuid, false) + + // THEN + // transfer is completed successfully + val slot = CapturingSlot() + verify { onTransferChanged(capture(slot)) } + assertEquals(TransferState.FAILED, slot.captured.state) + } + } + + class GetTransfers : Base() { + + val pendingTransferFile = OCFile("/pending") + val runningTransferFile = OCFile("/running") + val completedTransferFile = OCFile("/completed") + + lateinit var pendingTransferId: UUID + lateinit var runningTransferId: UUID + lateinit var completedTransferId: UUID + + @Before + fun setUp() { + completedTransferId = registry.add(DownloadRequest(user, completedTransferFile)) + registry.startNext() + registry.complete(completedTransferId, true) + + runningTransferId = registry.add(DownloadRequest(user, runningTransferFile)) + registry.startNext() + + pendingTransferId = registry.add(DownloadRequest(user, pendingTransferFile)) + resetMocks() + + assertEquals(1, registry.pending.size) + assertEquals(1, registry.running.size) + assertEquals(1, registry.completed.size) + } + + @Test + fun get_by_path_searches_pending_queue() { + // GIVEN + // file transfer is pending + + // WHEN + // transfer status is retrieved + val transfer = registry.getTransfer(pendingTransferFile) + + // THEN + // transfer from pending queue is returned + assertNotNull(transfer) + assertEquals(pendingTransferId, transfer?.uuid) + } + + @Test + fun get_by_id_searches_pending_queue() { + // GIVEN + // file transfer is pending + + // WHEN + // transfer status is retrieved + val transfer = registry.getTransfer(pendingTransferId) + + // THEN + // transfer from pending queue is returned + assertNotNull(transfer) + assertEquals(pendingTransferId, transfer?.uuid) + } + + @Test + fun get_by_path_searches_running_queue() { + // GIVEN + // file transfer is running + + // WHEN + // transfer status is retrieved + val transfer = registry.getTransfer(runningTransferFile) + + // THEN + // transfer from pending queue is returned + assertNotNull(transfer) + assertEquals(runningTransferId, transfer?.uuid) + } + + @Test + fun get_by_id_searches_running_queue() { + // GIVEN + // file transfer is running + + // WHEN + // transfer status is retrieved + val transfer = registry.getTransfer(runningTransferId) + + // THEN + // transfer from pending queue is returned + assertNotNull(transfer) + assertEquals(runningTransferId, transfer?.uuid) + } + + @Test + fun get_by_path_searches_completed_queue() { + // GIVEN + // file transfer is pending + + // WHEN + // transfer status is retrieved + val transfer = registry.getTransfer(completedTransferFile) + + // THEN + // transfer from pending queue is returned + assertNotNull(transfer) + assertEquals(completedTransferId, transfer?.uuid) + } + + @Test + fun get_by_id_searches_completed_queue() { + // GIVEN + // file transfer is pending + + // WHEN + // transfer status is retrieved + val transfer = registry.getTransfer(completedTransferId) + + // THEN + // transfer from pending queue is returned + assertNotNull(transfer) + assertEquals(completedTransferId, transfer?.uuid) + } + + @Test + fun not_found_by_path() { + // GIVEN + // no transfer for a file + val nonExistingTransferFile = OCFile("/non-nexisting/transfer") + + // WHEN + // transfer status is retrieved for a file + val transfer = registry.getTransfer(nonExistingTransferFile) + + // THEN + // no transfer is found + assertNull(transfer) + } + + @Test + fun not_found_by_id() { + // GIVEN + // no transfer for an id + val nonExistingId = UUID.randomUUID() + + // WHEN + // transfer status is retrieved for a file + val transfer = registry.getTransfer(nonExistingId) + + // THEN + // no transfer is found + assertNull(transfer) + } + } + + class IsRunning : Base() { + + @Test + fun no_requests() { + // WHEN + // all queues empty + assertEquals(0, registry.pending.size) + assertEquals(0, registry.running.size) + assertEquals(0, registry.completed.size) + + // THEN + // not running + assertFalse(registry.isRunning) + } + + @Test + fun request_pending() { + // WHEN + // request is enqueued + val request = DownloadRequest(user, OCFile("/path/alpha/1")) + registry.add(request) + assertEquals(1, registry.pending.size) + assertEquals(0, registry.running.size) + assertEquals(0, registry.completed.size) + + // THEN + // is running + assertTrue(registry.isRunning) + } + + @Test + fun request_running() { + // WHEN + // request is running + val request = DownloadRequest(user, OCFile("/path/alpha/1")) + registry.add(request) + registry.startNext() + assertEquals(0, registry.pending.size) + assertEquals(1, registry.running.size) + assertEquals(0, registry.completed.size) + + // THEN + // is running + assertTrue(registry.isRunning) + } + + @Test + fun request_completed() { + // WHEN + // request is running + val request = DownloadRequest(user, OCFile("/path/alpha/1")) + val id = registry.add(request) + registry.startNext() + registry.complete(id, true) + assertEquals(0, registry.pending.size) + assertEquals(0, registry.running.size) + assertEquals(1, registry.completed.size) + + // THEN + // is not running + assertFalse(registry.isRunning) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/files/download/TransferManagerConnectionTest.kt b/app/src/androidTest/java/com/nextcloud/client/files/download/TransferManagerConnectionTest.kt new file mode 100644 index 000000000000..231cf26a884b --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/files/download/TransferManagerConnectionTest.kt @@ -0,0 +1,233 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files.download + +import android.content.ComponentName +import android.content.Context +import com.nextcloud.client.account.MockUser +import com.nextcloud.client.files.DownloadRequest +import com.nextcloud.client.jobs.transfer.FileTransferService +import com.nextcloud.client.jobs.transfer.Transfer +import com.nextcloud.client.jobs.transfer.TransferManager +import com.nextcloud.client.jobs.transfer.TransferManagerConnection +import com.nextcloud.client.jobs.transfer.TransferState +import com.owncloud.android.datamodel.OCFile +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TransferManagerConnectionTest { + + lateinit var connection: TransferManagerConnection + + @MockK + lateinit var context: Context + + @MockK + lateinit var firstDownloadListener: (Transfer) -> Unit + + @MockK + lateinit var secondDownloadListener: (Transfer) -> Unit + + @MockK + lateinit var firstStatusListener: (TransferManager.Status) -> Unit + + @MockK + lateinit var secondStatusListener: (TransferManager.Status) -> Unit + + @MockK + lateinit var binder: FileTransferService.Binder + + val file get() = OCFile("/path") + val componentName = ComponentName("", FileTransferService::class.java.simpleName) + val user = MockUser() + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + connection = TransferManagerConnection(context, user) + } + + @Test + fun listeners_are_set_after_connection() { + // GIVEN + // not connected + // listener is added + connection.registerTransferListener(firstDownloadListener) + connection.registerTransferListener(secondDownloadListener) + + // WHEN + // service is bound + connection.onServiceConnected(componentName, binder) + + // THEN + // all listeners are passed to the service + val listeners = mutableListOf<(Transfer) -> Unit>() + verify { binder.registerTransferListener(capture(listeners)) } + assertEquals(listOf(firstDownloadListener, secondDownloadListener), listeners) + } + + @Test + fun listeners_are_set_immediately_when_connected() { + // GIVEN + // service is bound + connection.onServiceConnected(componentName, binder) + + // WHEN + // listeners are added + connection.registerTransferListener(firstDownloadListener) + + // THEN + // listener is forwarded to service + verify { binder.registerTransferListener(firstDownloadListener) } + } + + @Test + fun listeners_are_removed_when_unbinding() { + // GIVEN + // service is bound + // service has some listeners + connection.onServiceConnected(componentName, binder) + connection.registerTransferListener(firstDownloadListener) + connection.registerTransferListener(secondDownloadListener) + + // WHEN + // service unbound + connection.unbind() + + // THEN + // listeners removed from service + verify { binder.removeTransferListener(firstDownloadListener) } + verify { binder.removeTransferListener(secondDownloadListener) } + } + + @Test + fun missed_updates_are_delivered_on_connection() { + // GIVEN + // not bound + // has listeners + // download is scheduled and is progressing + connection.registerTransferListener(firstDownloadListener) + connection.registerTransferListener(secondDownloadListener) + + val request1 = DownloadRequest(user, file) + connection.enqueue(request1) + val download1 = Transfer(request1.uuid, TransferState.RUNNING, 50, request1.file, request1) + + val request2 = DownloadRequest(user, file) + connection.enqueue(request2) + val download2 = Transfer(request2.uuid, TransferState.RUNNING, 50, request2.file, request1) + + every { binder.getTransfer(request1.uuid) } returns download1 + every { binder.getTransfer(request2.uuid) } returns download2 + + // WHEN + // service is bound + connection.onServiceConnected(componentName, binder) + + // THEN + // listeners receive current download state for pending downloads + val firstListenerNotifications = mutableListOf() + verify { firstDownloadListener(capture(firstListenerNotifications)) } + assertEquals(listOf(download1, download2), firstListenerNotifications) + + val secondListenerNotifications = mutableListOf() + verify { secondDownloadListener(capture(secondListenerNotifications)) } + assertEquals(listOf(download1, download2), secondListenerNotifications) + } + + @Test + fun downloader_status_updates_are_delivered_on_connection() { + // GIVEN + // not bound + // has status listeners + val mockStatus: TransferManager.Status = mockk() + every { binder.status } returns mockStatus + connection.registerStatusListener(firstStatusListener) + connection.registerStatusListener(secondStatusListener) + + // WHEN + // service is bound + connection.onServiceConnected(componentName, binder) + + // THEN + // downloader status is delivered + verify { firstStatusListener(mockStatus) } + verify { secondStatusListener(mockStatus) } + } + + @Test + fun downloader_status_not_requested_if_no_listeners() { + // GIVEN + // not bound + // no status listeners + + // WHEN + // service is bound + connection.onServiceConnected(componentName, binder) + + // THEN + // downloader status is not requested + verify(exactly = 0) { binder.status } + } + + @Test + fun not_running_if_not_connected() { + // GIVEN + // downloader is running + // connection not bound + every { binder.isRunning } returns true + + // THEN + // not running + assertFalse(connection.isRunning) + } + + @Test + fun is_running_from_binder_if_connected() { + // GIVEN + // service bound + every { binder.isRunning } returns true + connection.onServiceConnected(componentName, binder) + + // WHEN + // is running flag accessed + val isRunning = connection.isRunning + + // THEN + // call delegated to binder + assertTrue(isRunning) + verify(exactly = 1) { binder.isRunning } + } + + @Test + fun missed_updates_not_tracked_before_listeners_registered() { + // GIVEN + // not bound + // some downloads requested without listener + val request = DownloadRequest(user, file) + connection.enqueue(request) + val download = Transfer(request.uuid, TransferState.RUNNING, 50, request.file, request) + connection.registerTransferListener(firstDownloadListener) + every { binder.getTransfer(request.uuid) } returns download + + // WHEN + // service is bound + connection.onServiceConnected(componentName, binder) + + // THEN + // missed updates not redelivered + verify(exactly = 0) { firstDownloadListener(any()) } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/files/download/TransferManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/files/download/TransferManagerTest.kt new file mode 100644 index 000000000000..e5983fe11025 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/files/download/TransferManagerTest.kt @@ -0,0 +1,279 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files.download + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.nextcloud.client.account.User +import com.nextcloud.client.core.ManualAsyncRunner +import com.nextcloud.client.core.OnProgressCallback +import com.nextcloud.client.files.DownloadRequest +import com.nextcloud.client.files.Request +import com.nextcloud.client.jobs.download.DownloadTask +import com.nextcloud.client.jobs.transfer.Transfer +import com.nextcloud.client.jobs.transfer.TransferManagerImpl +import com.nextcloud.client.jobs.transfer.TransferState +import com.nextcloud.client.jobs.upload.UploadTask +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.mockito.MockitoAnnotations + +@RunWith(Suite::class) +@Suite.SuiteClasses( + TransferManagerTest.Enqueue::class, + TransferManagerTest.TransferStatusUpdates::class +) +class TransferManagerTest { + + abstract class Base { + + companion object { + const val MAX_TRANSFER_THREADS = 4 + } + + @MockK + lateinit var user: User + + @MockK + lateinit var client: OwnCloudClient + + @MockK + lateinit var mockDownloadTaskFactory: DownloadTask.Factory + + @MockK + lateinit var mockUploadTaskFactory: UploadTask.Factory + + /** + * All task mock functions created during test run are + * stored here. + */ + lateinit var downloadTaskMocks: MutableList + lateinit var runner: ManualAsyncRunner + lateinit var transferManager: TransferManagerImpl + + /** + * Response value for all download tasks + */ + var downloadTaskResult: Boolean = true + + /** + * Progress values posted by all download task mocks before + * returning result value + */ + var taskProgress = listOf() + + @Before + fun setUpBase() { + MockKAnnotations.init(this, relaxed = true) + MockitoAnnotations.initMocks(this) + downloadTaskMocks = mutableListOf() + runner = ManualAsyncRunner() + transferManager = TransferManagerImpl( + runner = runner, + downloadTaskFactory = mockDownloadTaskFactory, + uploadTaskFactory = mockUploadTaskFactory, + threads = MAX_TRANSFER_THREADS + ) + downloadTaskResult = true + every { mockDownloadTaskFactory.create() } answers { createMockTask() } + } + + private fun createMockTask(): DownloadTask { + val task = mockk() + every { task.download(any(), any(), any()) } answers { + taskProgress.forEach { + arg>(1).invoke(it) + } + val request = arg(0) + DownloadTask.Result(request.file, downloadTaskResult) + } + downloadTaskMocks.add(task) + return task + } + } + + class Enqueue : Base() { + + @Test + fun enqueued_download_is_started_immediately() { + // GIVEN + // downloader has no running downloads + + // WHEN + // download is enqueued + val file = OCFile("/path") + val request = DownloadRequest(user, file) + transferManager.enqueue(request) + + // THEN + // download is started immediately + val download = transferManager.getTransfer(request.uuid) + assertEquals(TransferState.RUNNING, download?.state) + } + + @Test + fun enqueued_downloads_are_pending_if_running_queue_is_full() { + // GIVEN + // downloader is downloading max simultaneous files + for (i in 0 until MAX_TRANSFER_THREADS) { + val file = OCFile("/running/download/path/$i") + val request = DownloadRequest(user, file) + transferManager.enqueue(request) + val runningDownload = transferManager.getTransfer(request.uuid) + assertEquals(runningDownload?.state, TransferState.RUNNING) + } + + // WHEN + // another download is enqueued + val file = OCFile("/path") + val request = DownloadRequest(user, file) + transferManager.enqueue(request) + + // THEN + // download is pending + val download = transferManager.getTransfer(request.uuid) + assertEquals(TransferState.PENDING, download?.state) + } + } + + class TransferStatusUpdates : Base() { + + @get:Rule + val rule = InstantTaskExecutorRule() + + val file = OCFile("/path") + + @Test + fun download_task_completes() { + // GIVEN + // download is running + // download is being observed + val downloadUpdates = mutableListOf() + transferManager.registerTransferListener { downloadUpdates.add(it) } + transferManager.enqueue(DownloadRequest(user, file)) + + // WHEN + // download task finishes successfully + runner.runOne() + + // THEN + // listener is notified about status change + assertEquals(TransferState.RUNNING, downloadUpdates[0].state) + assertEquals(TransferState.COMPLETED, downloadUpdates[1].state) + } + + @Test + fun download_task_fails() { + // GIVEN + // download is running + // download is being observed + val downloadUpdates = mutableListOf() + transferManager.registerTransferListener { downloadUpdates.add(it) } + transferManager.enqueue(DownloadRequest(user, file)) + + // WHEN + // download task fails + downloadTaskResult = false + runner.runOne() + + // THEN + // listener is notified about status change + assertEquals(TransferState.RUNNING, downloadUpdates[0].state) + assertEquals(TransferState.FAILED, downloadUpdates[1].state) + } + + @Test + fun download_progress_is_updated() { + // GIVEN + // download is running + val downloadUpdates = mutableListOf() + transferManager.registerTransferListener { downloadUpdates.add(it) } + transferManager.enqueue(DownloadRequest(user, file)) + + // WHEN + // download progress updated 4 times before completion + taskProgress = listOf(25, 50, 75, 100) + runner.runOne() + + // THEN + // listener receives 6 status updates + // transition to running + // 4 progress updates + // completion + assertEquals(6, downloadUpdates.size) + if (downloadUpdates.size >= 6) { + assertEquals(TransferState.RUNNING, downloadUpdates[0].state) + assertEquals(25, downloadUpdates[1].progress) + assertEquals(50, downloadUpdates[2].progress) + assertEquals(75, downloadUpdates[3].progress) + assertEquals(100, downloadUpdates[4].progress) + assertEquals(TransferState.COMPLETED, downloadUpdates[5].state) + } + } + + @Test + fun download_task_is_created_only_for_running_downloads() { + // WHEN + // multiple downloads are enqueued + for (i in 0 until MAX_TRANSFER_THREADS * 2) { + transferManager.enqueue(DownloadRequest(user, file)) + } + + // THEN + // download task is created only for running downloads + assertEquals(MAX_TRANSFER_THREADS, downloadTaskMocks.size) + } + } + + class RunningStatusUpdates : Base() { + + @get:Rule + val rule = InstantTaskExecutorRule() + + @Test + fun is_running_flag_on_enqueue() { + // WHEN + // download is enqueued + val file = OCFile("/path/to/file") + val request = DownloadRequest(user, file) + transferManager.enqueue(request) + + // THEN + // is running changes + assertTrue(transferManager.isRunning) + } + + @Test + fun is_running_flag_on_completion() { + // GIVEN + // a download is in progress + val file = OCFile("/path/to/file") + val request = DownloadRequest(user, file) + transferManager.enqueue(request) + assertTrue(transferManager.isRunning) + + // WHEN + // download is processed + runner.runOne() + + // THEN + // downloader is not running + assertFalse(transferManager.isRunning) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/integrations/deck/DeckApiTest.kt b/app/src/androidTest/java/com/nextcloud/client/integrations/deck/DeckApiTest.kt new file mode 100644 index 000000000000..46abfc037c7e --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/integrations/deck/DeckApiTest.kt @@ -0,0 +1,164 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.integrations.deck + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import androidx.test.platform.app.InstrumentationRegistry +import com.nextcloud.client.account.User +import com.owncloud.android.lib.resources.notifications.models.Notification +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Suite +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(Suite::class) +@Suite.SuiteClasses( + DeckApiTest.DeckIsInstalled::class, + DeckApiTest.DeckIsNotInstalled::class +) +class DeckApiTest { + + abstract class Fixture { + @Mock + lateinit var packageManager: PackageManager + + lateinit var context: Context + + @Mock + lateinit var user: User + + lateinit var deck: DeckApiImpl + + @Before + fun setUpFixture() { + MockitoAnnotations.initMocks(this) + context = InstrumentationRegistry.getInstrumentation().targetContext + deck = DeckApiImpl(context, packageManager) + } + } + + @RunWith(Parameterized::class) + class DeckIsInstalled : Fixture() { + + @Parameterized.Parameter(0) + lateinit var installedDeckPackage: String + + companion object { + @Parameterized.Parameters + @JvmStatic + fun initParametrs(): Array = DeckApiImpl.DECK_APP_PACKAGES + } + + @Before + fun setUp() { + whenever(packageManager.resolveActivity(any(), anyInt())).thenAnswer { + val intent = it.getArgument(0) + return@thenAnswer if (intent.component?.packageName == installedDeckPackage) { + ResolveInfo() + } else { + null + } + } + } + + @Test + fun can_forward_deck_notification() { + // GIVEN + // notification to deck arrives + val notification = Notification().apply { app = "deck" } + + // WHEN + // deck action is created + val forwardActionIntent = deck.createForwardToDeckActionIntent(notification, user) + + // THEN + // open action is created + assertTrue("Failed for $installedDeckPackage", forwardActionIntent.isPresent) + } + + @Test + fun notifications_from_other_apps_are_ignored() { + // GIVEN + // notification from other app arrives + val deckNotification = Notification().apply { + app = "some_other_app" + } + + // WHEN + // deck action is created + val openDeckActionIntent = deck.createForwardToDeckActionIntent(deckNotification, user) + + // THEN + // deck application is not being resolved + // open action is not created + verify(packageManager, never()).resolveActivity(anyOrNull(), anyOrNull()) + assertFalse(openDeckActionIntent.isPresent) + } + } + + class DeckIsNotInstalled : Fixture() { + + @Before + fun setUp() { + whenever(packageManager.resolveActivity(any(), anyInt())).thenReturn(null) + } + + @Test + fun cannot_forward_deck_notification() { + // GIVEN + // notification is coming from deck app + val notification = Notification().apply { + app = DeckApiImpl.APP_NAME + } + + // WHEN + // creating open in deck action + val openDeckActionIntent = deck.createForwardToDeckActionIntent(notification, user) + + // THEN + // deck application is being resolved using all known packages + // open action is not created + verify(packageManager, times(DeckApiImpl.DECK_APP_PACKAGES.size)) + .resolveActivity(anyOrNull(), anyOrNull()) + assertFalse(openDeckActionIntent.isPresent) + } + + @Test + fun notifications_from_other_apps_are_ignored() { + // GIVEN + // notification is coming from other app + val notification = Notification().apply { + app = "some_other_app" + } + + // WHEN + // creating open in deck action + val openDeckActionIntent = deck.createForwardToDeckActionIntent(notification, user) + + // THEN + // deck application is not being resolved + // open action is not created + verify(packageManager, never()).resolveActivity(anyOrNull(), anyOrNull()) + assertFalse(openDeckActionIntent.isPresent) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt new file mode 100644 index 000000000000..d59256f786c0 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt @@ -0,0 +1,449 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.test.annotation.UiThreadTest +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.nextcloud.client.account.User +import com.nextcloud.client.core.Clock +import com.nextcloud.utils.extensions.toByteArray +import com.owncloud.android.lib.common.utils.Log_OC +import org.apache.commons.io.FileUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.mockito.ArgumentMatcher +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.io.IOException +import java.util.Date +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * When using IDE to run enire Suite, make sure tests are run using Android Instrumentation Test + * runner. By default IDE runs normal JUnit - this is AS problem. One must configure the + * test run manually. + */ +@RunWith(Suite::class) +@Suite.SuiteClasses( + BackgroundJobManagerTest.Manager::class, + BackgroundJobManagerTest.ContentObserver::class, + BackgroundJobManagerTest.PeriodicContactsBackup::class, + BackgroundJobManagerTest.ImmediateContactsBackup::class, + BackgroundJobManagerTest.ImmediateContactsImport::class, + BackgroundJobManagerTest.Tags::class +) +class BackgroundJobManagerTest { + + /** + * Used to help with ambiguous type inference + */ + class IsOneTimeWorkRequest : ArgumentMatcher { + override fun matches(argument: OneTimeWorkRequest?): Boolean = true + } + + /** + * Used to help with ambiguous type inference + */ + class IsPeriodicWorkRequest : ArgumentMatcher { + override fun matches(argument: PeriodicWorkRequest?): Boolean = true + } + + abstract class Fixture { + companion object { + internal const val USER_ACCOUNT_NAME = "user@nextcloud" + internal val TIMESTAMP = System.currentTimeMillis() + } + internal lateinit var user: User + internal lateinit var workManager: WorkManager + internal lateinit var clock: Clock + internal lateinit var backgroundJobManager: BackgroundJobManagerImpl + internal lateinit var context: Context + + @Before + fun setUpFixture() { + context = mock() + user = mock() + whenever(user.accountName).thenReturn(USER_ACCOUNT_NAME) + workManager = mock() + clock = mock() + whenever(clock.currentTime).thenReturn(TIMESTAMP) + whenever(clock.currentDate).thenReturn(Date(TIMESTAMP)) + backgroundJobManager = BackgroundJobManagerImpl(workManager, clock, mock()) + } + + fun assertHasRequiredTags(tags: Set, jobName: String, user: User? = null) { + assertTrue("""'all' tag is mandatory""", tags.contains("*")) + assertTrue("name tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatNameTag(jobName, user))) + assertTrue("timestamp tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatTimeTag(TIMESTAMP))) + if (user != null) { + assertTrue("user tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatUserTag(user))) + } + } + + fun buildWorkInfo(index: Long): WorkInfo = WorkInfo( + id = UUID.randomUUID(), + state = WorkInfo.State.RUNNING, + outputData = Data.Builder().build(), + tags = setOf(BackgroundJobManagerImpl.formatTimeTag(1581820284000)), + progress = Data.Builder().build(), + runAttemptCount = 1, + generation = 0 + ) + } + + class Manager : Fixture() { + + class SyncObserver : Observer { + val latch = CountDownLatch(1) + var value: T? = null + override fun onChanged(t: T) { + value = t + latch.countDown() + } + + fun getValue(timeout: Long = 3, timeUnit: TimeUnit = TimeUnit.SECONDS): T? { + val result = latch.await(timeout, timeUnit) + if (!result) { + throw TimeoutException() + } + return value + } + } + + @Test + @UiThreadTest + fun get_all_job_info() { + // GIVEN + // work manager has 2 registered workers + val platformWorkInfo = listOf( + buildWorkInfo(0), + buildWorkInfo(1), + buildWorkInfo(2) + ) + val lv = MutableLiveData>() + lv.value = platformWorkInfo + whenever(workManager.getWorkInfosByTagLiveData(eq("*"))).thenReturn(lv) + + // WHEN + // job info for all jobs is requested + val jobs = backgroundJobManager.jobs + + // THEN + // live data with job info is returned + // live data contains 2 job info instances + // job info is sorted by timestamp from newest to oldest + assertNotNull(jobs) + val observer = SyncObserver>() + jobs.observeForever(observer) + val jobInfo = observer.getValue() + assertNotNull(jobInfo) + assertEquals(platformWorkInfo.size, jobInfo?.size) + jobInfo?.let { + assertEquals(platformWorkInfo[2].id, it[0].id) + assertEquals(platformWorkInfo[1].id, it[1].id) + assertEquals(platformWorkInfo[0].id, it[2].id) + } + } + + @Test + fun cancel_all_jobs() { + // WHEN + // all jobs are cancelled + backgroundJobManager.cancelAllJobs() + + // THEN + // all jobs with * tag are cancelled + verify(workManager).cancelAllWorkByTag(BackgroundJobManagerImpl.TAG_ALL) + } + } + + class ContentObserver : Fixture() { + + private lateinit var request: OneTimeWorkRequest + + @Before + fun setUp() { + val requestCaptor: KArgumentCaptor = argumentCaptor() + backgroundJobManager.scheduleContentObserverJob() + verify(workManager).enqueueUniqueWork( + any(), + any(), + requestCaptor.capture() + ) + assertEquals(1, requestCaptor.allValues.size) + request = requestCaptor.firstValue + } + + @Test + fun job_is_unique_and_replaces_previous_job() { + verify(workManager).enqueueUniqueWork( + eq(BackgroundJobManagerImpl.JOB_CONTENT_OBSERVER), + eq(ExistingWorkPolicy.REPLACE), + argThat(IsOneTimeWorkRequest()) + ) + } + + @Test + fun job_request_has_mandatory_tags() { + assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_CONTENT_OBSERVER) + } + } + + class PeriodicContactsBackup : Fixture() { + private lateinit var request: PeriodicWorkRequest + + @Before + fun setUp() { + val requestCaptor: KArgumentCaptor = argumentCaptor() + backgroundJobManager.schedulePeriodicContactsBackup(user) + verify(workManager).enqueueUniquePeriodicWork( + any(), + any(), + requestCaptor.capture() + ) + assertEquals(1, requestCaptor.allValues.size) + request = requestCaptor.firstValue + } + + @Test + fun job_is_unique_for_user() { + verify(workManager).enqueueUniquePeriodicWork( + eq(BackgroundJobManagerImpl.JOB_PERIODIC_CONTACTS_BACKUP), + eq(ExistingPeriodicWorkPolicy.KEEP), + argThat(IsPeriodicWorkRequest()) + ) + } + + @Test + fun job_request_has_mandatory_tags() { + assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_PERIODIC_CONTACTS_BACKUP, user) + } + } + + class ImmediateContactsBackup : Fixture() { + + private lateinit var workInfo: MutableLiveData + private lateinit var jobInfo: LiveData + private lateinit var request: OneTimeWorkRequest + + @Before + fun setUp() { + val requestCaptor: KArgumentCaptor = argumentCaptor() + workInfo = MutableLiveData() + whenever(workManager.getWorkInfoByIdLiveData(any())).thenReturn(workInfo) + jobInfo = backgroundJobManager.startImmediateContactsBackup(user) + verify(workManager).enqueueUniqueWork( + any(), + any(), + requestCaptor.capture() + ) + assertEquals(1, requestCaptor.allValues.size) + request = requestCaptor.firstValue + } + + @Test + fun job_is_unique_for_user() { + verify(workManager).enqueueUniqueWork( + eq(BackgroundJobManagerImpl.JOB_IMMEDIATE_CONTACTS_BACKUP), + eq(ExistingWorkPolicy.KEEP), + argThat(IsOneTimeWorkRequest()) + ) + } + + @Test + fun job_request_has_mandatory_tags() { + assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_IMMEDIATE_CONTACTS_BACKUP, user) + } + + @Test + @UiThreadTest + fun job_info_is_obtained_from_work_info() { + // GIVEN + // work info is available + workInfo.value = buildWorkInfo(0) + + // WHEN + // job info has listener + jobInfo.observeForever {} + + // THEN + // converted value is available + assertNotNull(jobInfo.value) + assertEquals(workInfo.value?.id, jobInfo.value?.id) + } + } + + class ImmediateContactsImport : Fixture() { + + private lateinit var workInfo: MutableLiveData + private lateinit var jobInfo: LiveData + private lateinit var request: OneTimeWorkRequest + + @get:Rule + var folder: TemporaryFolder = TemporaryFolder() + + @Before + fun setUp() { + var selectedContactsFile: File? = null + try { + selectedContactsFile = folder.newFile("hashset_cache.txt") + } catch (_: IOException) { + Log_OC.e("ImmediateContactsImport", "error creating temporary test file in ") + fail("hashset_cache cannot be found") + } + + if (selectedContactsFile == null) { + fail("hashset_cache cannot be found") + } + + val requestCaptor: KArgumentCaptor = argumentCaptor() + workInfo = MutableLiveData() + whenever(workManager.getWorkInfoByIdLiveData(any())).thenReturn(workInfo) + + val selectedContacts = intArrayOf(1, 2, 3) + val contractsAsByteArray = selectedContacts.toByteArray() + FileUtils.writeByteArrayToFile(selectedContactsFile, contractsAsByteArray) + + jobInfo = backgroundJobManager.startImmediateContactsImport( + contactsAccountName = "name", + contactsAccountType = "type", + vCardFilePath = "/path/to/vcard/file", + selectedContactsFilePath = selectedContactsFile!!.absolutePath + ) + verify(workManager).enqueueUniqueWork( + any(), + any(), + requestCaptor.capture() + ) + assertEquals(1, requestCaptor.allValues.size) + request = requestCaptor.firstValue + } + + @Test + fun job_is_unique() { + verify(workManager).enqueueUniqueWork( + eq(BackgroundJobManagerImpl.JOB_IMMEDIATE_CONTACTS_IMPORT), + eq(ExistingWorkPolicy.KEEP), + argThat(IsOneTimeWorkRequest()) + ) + } + + @Test + fun job_request_has_mandatory_tags() { + assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_IMMEDIATE_CONTACTS_IMPORT) + } + + @Test + @UiThreadTest + fun job_info_is_obtained_from_work_info() { + // GIVEN + // work info is available + workInfo.value = buildWorkInfo(0) + + // WHEN + // job info has listener + jobInfo.observeForever {} + + // THEN + // converted value is available + assertNotNull(jobInfo.value) + assertEquals(workInfo.value?.id, jobInfo.value?.id) + } + } + + class Tags { + @Test + fun split_tag_key_and_value() { + // GIVEN + // valid tag + // tag has colons in value part + val tag = "${BackgroundJobManagerImpl.TAG_PREFIX_NAME}:value:with:colons and spaces" + + // WHEN + // tag is parsed + val parsedTag = BackgroundJobManagerImpl.parseTag(tag) + + // THEN + // key-value pair is returned + // key is first + // value with colons is second + assertNotNull(parsedTag) + assertEquals(BackgroundJobManagerImpl.TAG_PREFIX_NAME, parsedTag?.first) + assertEquals("value:with:colons and spaces", parsedTag?.second) + } + + @Test + fun tags_with_invalid_prefixes_are_rejected() { + // GIVEN + // tag prefix is not on allowed prefixes list + val tag = "invalidprefix:value" + BackgroundJobManagerImpl.PREFIXES.forEach { + assertFalse(tag.startsWith(it)) + } + + // WHEN + // tag is parsed + val parsedTag = BackgroundJobManagerImpl.parseTag(tag) + + // THEN + // tag is rejected + assertNull(parsedTag) + } + + @Test + fun strings_without_colon_are_rejected() { + // GIVEN + // strings that are not tags + val tags = listOf( + BackgroundJobManagerImpl.TAG_ALL, + BackgroundJobManagerImpl.TAG_PREFIX_NAME, + "simplestring", + "" + ) + + tags.forEach { + // WHEN + // string is parsed + val parsedTag = BackgroundJobManagerImpl.parseTag(it) + + // THEN + // tag is rejected + assertNull(parsedTag) + } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/ContactsBackupIT.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/ContactsBackupIT.kt new file mode 100644 index 000000000000..bb68dfcfd153 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/ContactsBackupIT.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.jobs + +import android.Manifest +import androidx.test.rule.GrantPermissionRule +import androidx.work.WorkManager +import com.nextcloud.client.core.ClockImpl +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.preferences.AppPreferencesImpl +import com.nextcloud.test.RetryTestRule +import com.nextcloud.utils.extensions.toByteArray +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadFileOperation +import ezvcard.Ezvcard +import ezvcard.VCard +import org.apache.commons.io.FileUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.IOException + +class ContactsBackupIT : AbstractOnServerIT() { + private val workManager = WorkManager.getInstance(targetContext) + private val preferences: AppPreferences = AppPreferencesImpl.fromContext(targetContext) + private val backgroundJobManager = BackgroundJobManagerImpl(workManager, ClockImpl(), preferences) + + @get:Rule + val writeContactsRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_CONTACTS) + + @get:Rule + val readContactsRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS) + + @get:Rule + val retryTestRule = RetryTestRule() // flaky test + + @get:Rule + var folder: TemporaryFolder = TemporaryFolder() + + private val vcard: String = "vcard.vcf" + private var selectedContactsFile: File? = null + + @Before + fun setup() { + try { + selectedContactsFile = folder.newFile("hashset_cache.txt") + } catch (_: IOException) { + Log_OC.e("ContactsBackupIT", "error creating temporary test file in ") + } + } + + @Test + fun importExport() { + val intArray = intArrayOf(0) + if (selectedContactsFile == null) { + fail("hashset_cache cannot be found") + } + + val contractsAsByteArray = intArray.toByteArray() + FileUtils.writeByteArrayToFile(selectedContactsFile, contractsAsByteArray) + + // import file to local contacts + backgroundJobManager.startImmediateContactsImport( + null, + null, + getFile(vcard).absolutePath, + selectedContactsFile!!.absolutePath + ) + longSleep() + + // export contact + backgroundJobManager.startImmediateContactsBackup(user) + longSleep() + + val folderPath: String = targetContext.resources.getString(R.string.contacts_backup_folder) + + OCFile.PATH_SEPARATOR + + refreshFolder("/") + longSleep() + longSleep() + + refreshFolder(folderPath) + longSleep() + longSleep() + + if (folderPath.isEmpty()) { + fail("folderPath cannot be empty") + } + + val folder = fileDataStorageManager.getFileByDecryptedRemotePath(folderPath) + if (folder == null) { + fail("folder cannot be null") + } + + val ocFile = storageManager.getFolderContent(folder, false).firstOrNull() + if (ocFile == null) { + fail("ocFile cannot be null") + } + + if (ocFile?.storagePath == null) { + fail("ocFile.storagePath cannot be null") + } + + assertTrue(DownloadFileOperation(user, ocFile, targetContext).execute(client).isSuccess) + + val file = ocFile?.storagePath?.let { File(it) } + if (file == null) { + fail("file cannot be null") + } + + val vcardInputStream = BufferedInputStream(FileInputStream(getFile(vcard))) + val backupFileInputStream = BufferedInputStream(FileInputStream(file)) + + // verify same + val originalCards: ArrayList = ArrayList() + originalCards.addAll(Ezvcard.parse(vcardInputStream).all()) + + val backupCards: ArrayList = ArrayList() + backupCards.addAll(Ezvcard.parse(backupFileInputStream).all()) + + assertEquals(originalCards.size, backupCards.size) + + val originalCardFormattedName = originalCards.firstOrNull()?.formattedName + if (originalCardFormattedName == null) { + fail("originalCardFormattedName cannot be null") + } + + val backupCardFormattedName = backupCards.firstOrNull()?.formattedName + if (backupCardFormattedName == null) { + fail("backupCardFormattedName cannot be null") + } + + assertEquals(originalCardFormattedName.toString(), backupCardFormattedName.toString()) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/migrations/MigrationsDbTest.kt b/app/src/androidTest/java/com/nextcloud/client/migrations/MigrationsDbTest.kt new file mode 100644 index 000000000000..035ec3dbae83 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/migrations/MigrationsDbTest.kt @@ -0,0 +1,116 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class MigrationsDbTest { + + private lateinit var context: Context + private lateinit var store: MockSharedPreferences + private lateinit var db: MigrationsDb + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + store = MockSharedPreferences() + assertTrue("State from previous test run found?", store.all.isEmpty()) + db = MigrationsDb(store) + } + + @Test + fun applied_migrations_are_returned_in_order() { + // GIVEN + // some migrations are marked as applied + // migration ids are stored in random order + val mockStore: SharedPreferences = mock() + val storedMigrationIds = LinkedHashSet() + storedMigrationIds.apply { + add("3") + add("0") + add("2") + add("1") + } + whenever(mockStore.getStringSet(eq(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS), any())) + .thenReturn(storedMigrationIds) + + // WHEN + // applied migration ids are retrieved + val db = MigrationsDb(mockStore) + val ids = db.getAppliedMigrations() + + // THEN + // returned list is sorted + assertEquals(ids, ids.sorted()) + } + + @Test + @Suppress("MagicNumber") + fun registering_new_applied_migration_preserves_old_ids() { + // WHEN + // some applied migrations are registered + val appliedMigrationIds = setOf("0", "1", "2") + store.edit().putStringSet(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS, appliedMigrationIds).apply() + + // WHEN + // new set of migration ids are registered + // some ids are added again + db.addAppliedMigration(2, 3, 4) + + // THEN + // new ids are appended to set of existing ids + val expectedIds = setOf("0", "1", "2", "3", "4") + val storedIds = store.getStringSet(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS, mutableSetOf()) + assertEquals(expectedIds, storedIds) + } + + @Test + fun failed_status_sets_status_flag_and_error_message() { + // GIVEN + // failure flag is not set + assertFalse(db.isFailed) + + // WHEN + // failure status is set + val failureReason = "error message" + db.setFailed(0, failureReason) + + // THEN + // failed flag is set + // error message is set + assertTrue(db.isFailed) + assertEquals(failureReason, db.failureReason) + } + + @Test + fun last_migrated_version_is_set() { + // GIVEN + // last migrated version is not set + val oldVersion = db.lastMigratedVersion + assertEquals(MigrationsDb.NO_LAST_MIGRATED_VERSION, oldVersion) + + // WHEN + // migrated version is set to a new value + val newVersion = 200 + db.lastMigratedVersion = newVersion + + // THEN + // new value is stored + assertEquals(newVersion, db.lastMigratedVersion) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt new file mode 100644 index 000000000000..0d12bdaa3a16 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt @@ -0,0 +1,276 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +import androidx.test.annotation.UiThreadTest +import com.nextcloud.client.appinfo.AppInfo +import com.nextcloud.client.core.ManualAsyncRunner +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class MigrationsManagerTest { + + companion object { + const val OLD_APP_VERSION = 41 + const val NEW_APP_VERSION = 42 + } + + lateinit var migrationStep1Body: (Migrations.Step) -> Unit + lateinit var migrationStep1: Migrations.Step + + lateinit var migrationStep2Body: (Migrations.Step) -> Unit + lateinit var migrationStep2: Migrations.Step + + lateinit var migrationStep3Body: (Migrations.Step) -> Unit + lateinit var migrationStep3: Migrations.Step + + lateinit var migrations: List + + @Mock + lateinit var appInfo: AppInfo + + lateinit var migrationsDbStore: MockSharedPreferences + lateinit var migrationsDb: MigrationsDb + + lateinit var asyncRunner: ManualAsyncRunner + + internal lateinit var migrationsManager: MigrationsManagerImpl + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + migrationStep1Body = mock() + migrationStep1 = Migrations.Step(0, "first migration", true, migrationStep1Body) + + migrationStep2Body = mock() + migrationStep2 = Migrations.Step(1, "second optional migration", false, migrationStep2Body) + + migrationStep3Body = mock() + migrationStep3 = Migrations.Step(2, "third migration", true, migrationStep3Body) + + migrations = listOf(migrationStep1, migrationStep2, migrationStep3) + + asyncRunner = ManualAsyncRunner() + migrationsDbStore = MockSharedPreferences() + migrationsDb = MigrationsDb(migrationsDbStore) + + whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION) + migrationsManager = MigrationsManagerImpl( + appInfo = appInfo, + migrationsDb = migrationsDb, + asyncRunner = asyncRunner, + migrations = migrations + ) + } + + @Test + fun inital_status_is_unknown() { + // GIVEN + // migration manager has not been used yets + + // THEN + // status is not set + assertEquals(MigrationsManager.Status.UNKNOWN, migrationsManager.status.value) + } + + @Test + @UiThreadTest + fun migrations_are_scheduled_on_background_thread() { + // GIVEN + // migrations can be applied + assertEquals(0, migrationsDb.getAppliedMigrations().size) + + // WHEN + // migration is started + val count = migrationsManager.startMigration() + + // THEN + // all migrations are scheduled on background thread + // single task is scheduled + assertEquals(migrations.size, count) + assertEquals(1, asyncRunner.size) + assertEquals(MigrationsManager.Status.RUNNING, migrationsManager.status.value) + } + + @Test + @UiThreadTest + fun applied_migrations_are_recorded() { + // GIVEN + // no migrations are applied yet + // current app version is newer then last recorded migrated version + whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION) + migrationsDb.lastMigratedVersion = OLD_APP_VERSION + + // WHEN + // migration is run + val count = migrationsManager.startMigration() + assertTrue(asyncRunner.runOne()) + + // THEN + // total migrations count is returned + // migration functions are called with step as argument + // migrations are invoked in order + // applied migrations are recorded + // new app version code is recorded + assertEquals(migrations.size, count) + inOrder(migrationStep1.run, migrationStep2.run, migrationStep3.run).apply { + verify(migrationStep1.run).invoke(migrationStep1) + verify(migrationStep2.run).invoke(migrationStep2) + verify(migrationStep3.run).invoke(migrationStep3) + } + val allAppliedIds = migrations.map { it.id } + assertEquals(allAppliedIds, migrationsDb.getAppliedMigrations()) + assertEquals(NEW_APP_VERSION, migrationsDb.lastMigratedVersion) + } + + @Test + @UiThreadTest + fun previously_run_migrations_are_not_run_again() { + // GIVEN + // some migrations were run before + whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION) + migrationsDb.lastMigratedVersion = OLD_APP_VERSION + migrationsDb.addAppliedMigration(migrationStep1.id, migrationStep2.id) + + // WHEN + // migrations are applied + val count = migrationsManager.startMigration() + assertTrue(asyncRunner.runOne()) + + // THEN + // applied migrations count is returned + // previously applied migrations are not run + // required migrations are applied + // applied migrations are recorded + // new app version code is recorded + assertEquals(1, count) + verify(migrationStep1.run, never()).invoke(anyOrNull()) + verify(migrationStep2.run, never()).invoke(anyOrNull()) + verify(migrationStep3.run).invoke(migrationStep3) + val allAppliedIds = migrations.map { it.id } + assertEquals(allAppliedIds, migrationsDb.getAppliedMigrations()) + assertEquals(NEW_APP_VERSION, migrationsDb.lastMigratedVersion) + } + + @Test + @UiThreadTest + fun migration_error_is_recorded() { + // GIVEN + // no migrations applied yet + // no prior failed migrations + assertFalse(migrationsDb.isFailed) + assertEquals(MigrationsDb.NO_FAILED_MIGRATION_ID, migrationsDb.failedMigrationId) + + // WHEN + // migrations are applied + // one migration throws + val lastMigration = migrations.findLast { it.mandatory } ?: throw IllegalStateException("Test fixture error") + val errorMessage = "error message" + whenever(lastMigration.run.invoke(any())).thenThrow(RuntimeException(errorMessage)) + migrationsManager.startMigration() + assertTrue(asyncRunner.runOne()) + + // THEN + // failure is marked in the migration db + // failure message is recorded + // failed migration id is recorded + assertEquals(MigrationsManager.Status.FAILED, migrationsManager.status.value) + assertTrue(migrationsDb.isFailed) + assertEquals(errorMessage, migrationsDb.failureReason) + assertEquals(lastMigration.id, migrationsDb.failedMigrationId) + } + + @Test + @UiThreadTest + fun migrations_are_not_run_if_already_run_for_an_app_version() { + // GIVEN + // migrations were already run for the current app version + whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION) + migrationsDb.lastMigratedVersion = NEW_APP_VERSION + + // WHEN + // app is migrated again + val migrationCount = migrationsManager.startMigration() + + // THEN + // migration processing is skipped entirely + // status is set to applied + assertEquals(0, migrationCount) + listOf(migrationStep1, migrationStep2, migrationStep3).forEach { + verify(it.run, never()).invoke(any()) + } + assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value) + } + + @Test + @UiThreadTest + fun new_app_version_is_marked_as_migrated_if_no_new_migrations_are_available() { + // GIVEN + // migrations were applied in previous version + // new version has no new migrations + whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION) + migrationsDb.lastMigratedVersion = OLD_APP_VERSION + migrations.forEach { + migrationsDb.addAppliedMigration(it.id) + } + + // WHEN + // migration is started + val startedCount = migrationsManager.startMigration() + + // THEN + // no new migrations are run + // new version is marked as migrated + assertEquals(0, startedCount) + assertEquals( + NEW_APP_VERSION, + migrationsDb.lastMigratedVersion + ) + } + + @Test + @UiThreadTest + fun optional_migration_failure_does_not_trigger_a_migration_failure() { + // GIVEN + // pending migrations + // mandatory migrations are passing + // one migration is optional and fails + assertEquals("Fixture should provide 1 optional, failing migration", 1, migrations.count { !it.mandatory }) + val optionalFailingMigration = migrations.first { !it.mandatory } + whenever(optionalFailingMigration.run.invoke(any())).thenThrow(RuntimeException()) + + // WHEN + // migration is started + val startedCount = migrationsManager.startMigration() + asyncRunner.runOne() + assertEquals(migrations.size, startedCount) + + // THEN + // mandatory migrations are marked as applied + // optional failed migration is not marked + // no error + // status is applied + // failed migration is available during next migration + val appliedMigrations = migrations.filter { it.mandatory }.map { it.id } + assertTrue("Fixture error", appliedMigrations.isNotEmpty()) + assertEquals(appliedMigrations, migrationsDb.getAppliedMigrations()) + assertFalse(migrationsDb.isFailed) + assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt b/app/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt new file mode 100644 index 000000000000..bd871693b373 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +import android.content.SharedPreferences +import java.util.TreeMap + +/** + * This shared preferences implementation uses in-memory value store + * and it can be used in tests without using global, file-backed storage, + * improving test isolation. + * + * The implementation is not thread-safe. + */ +@Suppress("TooManyFunctions") +class MockSharedPreferences : SharedPreferences { + + class MockEditor(val store: MutableMap) : SharedPreferences.Editor { + + val editorStore: MutableMap = TreeMap() + + override fun clear(): SharedPreferences.Editor = throw UnsupportedOperationException() + + override fun putLong(key: String?, value: Long): SharedPreferences.Editor = + throw UnsupportedOperationException("Implement as needed") + + override fun putInt(key: String?, value: Int): SharedPreferences.Editor { + editorStore.put(key, value) + return this + } + + override fun remove(key: String?): SharedPreferences.Editor = throw UnsupportedOperationException() + + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor { + editorStore.put(key, value) + return this + } + + override fun putStringSet(key: String?, values: MutableSet?): SharedPreferences.Editor { + editorStore.put(key, values?.toMutableSet()) + return this + } + + override fun commit(): Boolean = true + + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = + throw UnsupportedOperationException("Implement as needed") + + override fun apply() = store.putAll(editorStore) + + override fun putString(key: String?, value: String?): SharedPreferences.Editor { + editorStore.put(key, value) + return this + } + } + + val store: MutableMap = TreeMap() + + override fun contains(key: String?): Boolean = store.containsKey(key) + override fun getBoolean(key: String?, defValue: Boolean): Boolean = store.getOrDefault(key, defValue) as Boolean + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener? + ) = throw UnsupportedOperationException() + + override fun getInt(key: String?, defValue: Int): Int = store.getOrDefault(key, defValue) as Int + + override fun getAll(): MutableMap = HashMap(store) + + override fun edit(): SharedPreferences.Editor = MockEditor(store) + + override fun getLong(key: String?, defValue: Long): Long = throw UnsupportedOperationException() + + override fun getFloat(key: String?, defValue: Float): Float = throw UnsupportedOperationException() + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + store.getOrDefault(key, defValues) as MutableSet? + + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener? + ) = throw UnsupportedOperationException() + + override fun getString(key: String?, defValue: String?): String? = store.getOrDefault(key, defValue) as String? +} diff --git a/app/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt b/app/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt new file mode 100644 index 000000000000..6654483e89c2 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt @@ -0,0 +1,87 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@Suppress("MagicNumber") +class MockSharedPreferencesTest { + + private lateinit var mock: MockSharedPreferences + + @Before + fun setUp() { + mock = MockSharedPreferences() + } + + @Test + fun getSetStringSet() { + val value = setOf("alpha", "bravo", "charlie") + mock.edit().putStringSet("key", value).apply() + val copy = mock.getStringSet("key", mutableSetOf()) + assertNotSame(value, copy) + assertEquals(value, copy) + } + + @Test + fun getSetInt() { + val value = 42 + val editor = mock.edit() + editor.putInt("key", value) + assertEquals(100, mock.getInt("key", 100)) + editor.apply() + assertEquals(42, mock.getInt("key", 100)) + } + + @Test + fun getSetBoolean() { + val value = true + val editor = mock.edit() + editor.putBoolean("key", value) + assertFalse(mock.getBoolean("key", false)) + editor.apply() + assertTrue(mock.getBoolean("key", false)) + } + + @Test + fun getSetString() { + val value = "a value" + val editor = mock.edit() + editor.putString("key", value) + assertEquals("default", mock.getString("key", "default")) + editor.apply() + assertEquals("a value", mock.getString("key", "default")) + } + + @Test + fun getAll() { + // GIVEN + // few properties are stored in shared preferences + mock.edit() + .putInt("int", 1) + .putBoolean("bool", true) + .putString("string", "value") + .putStringSet("stringSet", setOf("alpha", "bravo")) + .apply() + assertEquals(4, mock.store.size) + + // WHEN + // all properties are retrieved + val all = mock.all + + // THEN + // returned map is a different instance + // map is equal to internal storage + assertNotSame(all, mock.store) + assertEquals(all, mock.store) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/network/ConnectivityServiceImplIT.kt b/app/src/androidTest/java/com/nextcloud/client/network/ConnectivityServiceImplIT.kt new file mode 100644 index 000000000000..97ce82940e55 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/network/ConnectivityServiceImplIT.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.network + +import android.accounts.AccountManager +import android.content.Context +import android.net.ConnectivityManager +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.client.core.ClockImpl +import com.nextcloud.client.network.ConnectivityServiceImpl.GetRequestBuilder +import com.owncloud.android.AbstractOnServerIT +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectivityServiceImplIT : AbstractOnServerIT() { + @Test + fun testInternetWalled() { + val connectivityManager = targetContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val accountManager = targetContext.getSystemService(Context.ACCOUNT_SERVICE) as AccountManager + val userAccountManager = UserAccountManagerImpl(targetContext, accountManager) + val clientFactory = ClientFactoryImpl(targetContext) + val requestBuilder = GetRequestBuilder() + val walledCheckCache = WalledCheckCache(ClockImpl()) + + val sut = ConnectivityServiceImpl( + connectivityManager, + userAccountManager, + clientFactory, + requestBuilder, + walledCheckCache + ) + + assertTrue(sut.connectivity.isConnected) + assertFalse(sut.isInternetWalled) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/sso/SSOActivityTests.kt b/app/src/androidTest/java/com/nextcloud/client/sso/SSOActivityTests.kt new file mode 100644 index 000000000000..785468b43d51 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/sso/SSOActivityTests.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.sso + +import androidx.test.espresso.intent.rule.IntentsTestRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.ui.activity.SsoGrantPermissionActivity +import org.junit.Rule +import org.junit.Test + +class SSOActivityTests : AbstractIT() { + + @Suppress("DEPRECATION") + @get:Rule + var activityRule = IntentsTestRule(SsoGrantPermissionActivity::class.java, true, false) + + @Test + fun testActivityTheme() { + val sut = activityRule.launchActivity(null) + assert(sut.binding != null) + assert(sut.materialAlertDialogBuilder != null) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/extensions/BitmapDecodeTests.kt b/app/src/androidTest/java/com/nextcloud/extensions/BitmapDecodeTests.kt new file mode 100644 index 000000000000..e6b52cec3ad8 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/extensions/BitmapDecodeTests.kt @@ -0,0 +1,111 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.extensions + +import android.graphics.Bitmap +import com.nextcloud.utils.decodeSampledBitmapFromFile +import com.nextcloud.utils.extensions.toFile +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.absolutePathString +import kotlin.io.path.deleteRecursively +import kotlin.io.path.exists + +@Suppress("MagicNumber") +class BitmapDecodeTests { + + private lateinit var tempDir: Path + + @Before + fun setup() { + tempDir = Files.createTempDirectory("auto_upload_test_") + assertTrue("Temp directory should exist", tempDir.exists()) + } + + @OptIn(ExperimentalPathApi::class) + @After + fun cleanup() { + if (tempDir.exists()) { + tempDir.deleteRecursively() + } + } + + private fun createTempImageFile(width: Int = 100, height: Int = 100): Path { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val imagePath = tempDir.resolve("test_${System.currentTimeMillis()}.jpg") + + Files.newOutputStream(imagePath).use { out: OutputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + + assertTrue(imagePath.exists()) + return imagePath + } + + @Test + fun testToFileWhenPathIsValidShouldReturnExistingFile() { + val path = createTempImageFile() + val result = path.absolutePathString().toFile() + assertNotNull(result) + assertTrue(result!!.exists()) + } + + @Test + fun testToFileWhenPathIsEmptyShouldReturnNull() { + val result = "".toFile() + assertNull(result) + } + + @Test + fun testToFileWhenFileDoesNotExistShouldReturnNull() { + val nonExistentPath = tempDir.resolve("does_not_exist.jpg") + val result = nonExistentPath.absolutePathString().toFile() + assertNull(result) + } + + @Test + fun testDecodeSampledBitmapFromFileWhenValidPathShouldReturnBitmap() { + val path = createTempImageFile(400, 400) + val bitmap = decodeSampledBitmapFromFile(path.absolutePathString(), 100, 100) + assertNotNull(bitmap) + assertTrue(bitmap!!.width <= 400) + assertTrue(bitmap.height <= 400) + } + + @Test + fun testDecodeSampledBitmapFromFileWhenInvalidPathShouldReturnNull() { + val invalidPath = tempDir.resolve("invalid_path.jpg").absolutePathString() + val bitmap = decodeSampledBitmapFromFile(invalidPath, 100, 100) + assertNull(bitmap) + } + + @Test + fun testDecodeSampledBitmapFromFileWhenImageIsLargeShouldDownsampleBitmap() { + val path = createTempImageFile(2000, 2000) + val bitmap = decodeSampledBitmapFromFile(path.absolutePathString(), 100, 100) + assertNotNull(bitmap) + assertTrue("Bitmap should be smaller than original", bitmap!!.width < 2000 && bitmap.height < 2000) + } + + @Test + fun testDecodeSampledBitmapFromFileWhenImageIsSmallerThanRequestedShouldKeepOriginalSize() { + val path = createTempImageFile(100, 100) + val bitmap = decodeSampledBitmapFromFile(path.absolutePathString(), 200, 200) + assertNotNull(bitmap) + assertEquals(100, bitmap!!.width) + assertEquals(100, bitmap.height) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/extensions/BitmapRotationTests.kt b/app/src/androidTest/java/com/nextcloud/extensions/BitmapRotationTests.kt new file mode 100644 index 000000000000..a2394f461ad2 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/extensions/BitmapRotationTests.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.extensions + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.exifinterface.media.ExifInterface +import com.nextcloud.utils.rotateBitmapViaExif +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class BitmapRotationTests { + + private fun createTestBitmap(): Bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).apply { + setPixel(0, 0, Color.RED) + setPixel(1, 0, Color.GREEN) + setPixel(0, 1, Color.BLUE) + setPixel(1, 1, Color.YELLOW) + } + + @Test + fun testRotateBitmapViaExifWhenGivenNullBitmapShouldReturnNull() { + val rotated = null.rotateBitmapViaExif(ExifInterface.ORIENTATION_ROTATE_90) + assertEquals(null, rotated) + } + + @Test + fun testRotateBitmapViaExifWhenGivenNormalOrientationShouldReturnSameBitmap() { + val bmp = createTestBitmap() + val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_NORMAL) + assertEquals(bmp, rotated) + } + + @Test + fun testRotateBitmapViaExifWhenGivenRotate90ShouldReturnRotatedBitmap() { + val bmp = createTestBitmap() + val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_ROTATE_90)!! + assertEquals(bmp.width, rotated.height) + assertEquals(bmp.height, rotated.width) + + assertEquals(Color.BLUE, rotated.getPixel(0, 0)) + assertEquals(Color.RED, rotated.getPixel(1, 0)) + assertEquals(Color.YELLOW, rotated.getPixel(0, 1)) + assertEquals(Color.GREEN, rotated.getPixel(1, 1)) + } + + @Test + fun testRotateBitmapViaExifWhenGivenRotate180ShouldReturnRotatedBitmap() { + val bmp = createTestBitmap() + val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_ROTATE_180)!! + assertEquals(bmp.width, rotated.width) + assertEquals(bmp.height, rotated.height) + + assertEquals(Color.YELLOW, rotated.getPixel(0, 0)) + assertEquals(Color.BLUE, rotated.getPixel(1, 0)) + assertEquals(Color.GREEN, rotated.getPixel(0, 1)) + assertEquals(Color.RED, rotated.getPixel(1, 1)) + } + + @Test + fun testRotateBitmapViaExifWhenGivenFlipHorizontalShouldReturnFlippedBitmap() { + val bmp = createTestBitmap() + val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_FLIP_HORIZONTAL)!! + assertEquals(bmp.width, rotated.width) + assertEquals(bmp.height, rotated.height) + + assertEquals(Color.GREEN, rotated.getPixel(0, 0)) + assertEquals(Color.RED, rotated.getPixel(1, 0)) + assertEquals(Color.YELLOW, rotated.getPixel(0, 1)) + assertEquals(Color.BLUE, rotated.getPixel(1, 1)) + } + + @Test + fun testRotateBitmapViaExifWhenGivenFlipVerticalShouldReturnFlippedBitmap() { + val bmp = createTestBitmap() + val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_FLIP_VERTICAL)!! + assertEquals(bmp.width, rotated.width) + assertEquals(bmp.height, rotated.height) + + assertEquals(Color.BLUE, rotated.getPixel(0, 0)) + assertEquals(Color.YELLOW, rotated.getPixel(1, 0)) + assertEquals(Color.RED, rotated.getPixel(0, 1)) + assertEquals(Color.GREEN, rotated.getPixel(1, 1)) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/extensions/BundleExtensionTests.kt b/app/src/androidTest/java/com/nextcloud/extensions/BundleExtensionTests.kt new file mode 100644 index 000000000000..853a0e4a17cc --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/extensions/BundleExtensionTests.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.extensions + +import android.os.Bundle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.nextcloud.test.model.OtherTestData +import com.nextcloud.test.model.TestData +import com.nextcloud.test.model.TestDataParcelable +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.getSerializableArgument +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@Suppress("FunctionNaming") +@RunWith(AndroidJUnit4::class) +class BundleExtensionTests { + + private val key = "testDataKey" + + @Test + fun test_get_serializable_argument_when_given_valid_bundle_should_return_expected_data() { + val bundle = Bundle() + val testObject = TestData("Hello") + bundle.putSerializable(key, testObject) + val retrievedObject = bundle.getSerializableArgument(key, TestData::class.java) + assertEquals(testObject, retrievedObject) + } + + @Test + fun test_get_serializable_argument_when_given_valid_bundle_and_wrong_class_type_should_return_null() { + val bundle = Bundle() + val testObject = TestData("Hello") + bundle.putSerializable(key, testObject) + val retrievedObject = bundle.getSerializableArgument(key, Array::class.java) + assertNull(retrievedObject) + } + + @Test + fun test_get_parcelable_argument_when_given_valid_bundle_and_wrong_class_type_should_return_null() { + val bundle = Bundle() + val testObject = TestData("Hello") + bundle.putSerializable(key, testObject) + val retrievedObject = bundle.getParcelableArgument(key, OtherTestData::class.java) + assertNull(retrievedObject) + } + + @Test + fun test_get_parcelable_argument_when_given_valid_bundle_should_return_expected_data() { + val bundle = Bundle() + val testObject = TestDataParcelable("Hello") + bundle.putParcelable(key, testObject) + val retrievedObject = bundle.getParcelableArgument(key, TestDataParcelable::class.java) + assertEquals(testObject, retrievedObject) + } + + @Test + fun test_get_serializable_argument_when_given_null_bundle_should_return_null() { + val retrievedObject = (null as Bundle?).getSerializableArgument(key, TestData::class.java) + assertNull(retrievedObject) + } + + @Test + fun test_get_parcelable_argument_when_given_null_bundle_should_return_null() { + val retrievedObject = (null as Bundle?).getParcelableArgument(key, TestDataParcelable::class.java) + assertNull(retrievedObject) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/extensions/GetExifOrientationTests.kt b/app/src/androidTest/java/com/nextcloud/extensions/GetExifOrientationTests.kt new file mode 100644 index 000000000000..3475c8e1f229 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/extensions/GetExifOrientationTests.kt @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.extensions +import android.graphics.Bitmap +import android.graphics.Color +import androidx.exifinterface.media.ExifInterface +import com.nextcloud.utils.extensions.getExifOrientation +import junit.framework.TestCase.assertEquals +import org.junit.After +import org.junit.Test +import java.io.File + +class GetExifOrientationTests { + + private val tempFiles = mutableListOf() + + @Suppress("MagicNumber") + private fun createTempImageFile(): File { + val file = File.createTempFile("test_image", ".jpg") + tempFiles.add(file) + + val bmp = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).apply { + setPixel(0, 0, Color.RED) + setPixel(1, 0, Color.GREEN) + setPixel(0, 1, Color.BLUE) + setPixel(1, 1, Color.YELLOW) + } + + file.outputStream().use { out -> + bmp.compress(Bitmap.CompressFormat.JPEG, 100, out) + } + + return file + } + + @After + fun cleanup() { + tempFiles.forEach { it.delete() } + } + + @Test + fun testGetExifOrientationWhenExifIsRotate90ShouldReturnRotate90() { + val file = createTempImageFile() + + val exif = ExifInterface(file.absolutePath) + exif.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_ROTATE_90.toString()) + exif.saveAttributes() + + val orientation = getExifOrientation(file.absolutePath) + + assertEquals(ExifInterface.ORIENTATION_ROTATE_90, orientation) + } + + @Test + fun testGetExifOrientationWhenExifIsRotate180ShouldReturnRotate180() { + val file = createTempImageFile() + + val exif = ExifInterface(file.absolutePath) + exif.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_ROTATE_180.toString()) + exif.saveAttributes() + + val orientation = getExifOrientation(file.absolutePath) + assertEquals(ExifInterface.ORIENTATION_ROTATE_180, orientation) + } + + @Test + fun testGetExifOrientationWhenExifIsUndefinedShouldReturnUndefined() { + val file = createTempImageFile() + + val orientation = getExifOrientation(file.absolutePath) + assertEquals(ExifInterface.ORIENTATION_UNDEFINED, orientation) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/extensions/IntentExtensionTests.kt b/app/src/androidTest/java/com/nextcloud/extensions/IntentExtensionTests.kt new file mode 100644 index 000000000000..6fa385bdef63 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/extensions/IntentExtensionTests.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.extensions + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.nextcloud.test.model.OtherTestData +import com.nextcloud.test.model.TestData +import com.nextcloud.test.model.TestDataParcelable +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.getSerializableArgument +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@Suppress("FunctionNaming") +@RunWith(AndroidJUnit4::class) +class IntentExtensionTests { + + private val key = "testDataKey" + + @Test + fun test_get_serializable_argument_when_given_valid_intent_should_return_expected_data() { + val intent = Intent() + val testObject = TestData("Hello") + intent.putExtra(key, testObject) + val retrievedObject = intent.getSerializableArgument(key, TestData::class.java) + assertEquals(testObject, retrievedObject) + } + + @Test + fun test_get_serializable_argument_when_given_valid_intent_and_wrong_class_type_should_return_null() { + val intent = Intent() + val testObject = TestData("Hello") + intent.putExtra(key, testObject) + val retrievedObject = intent.getSerializableArgument(key, Array::class.java) + assertNull(retrievedObject) + } + + @Test + fun test_get_parcelable_argument_when_given_valid_intent_and_wrong_class_type_should_return_null() { + val intent = Intent() + val testObject = TestData("Hello") + intent.putExtra(key, testObject) + val retrievedObject = intent.getParcelableArgument(key, OtherTestData::class.java) + assertNull(retrievedObject) + } + + @Test + fun test_get_parcelable_argument_when_given_valid_intent_should_return_expected_data() { + val intent = Intent() + val testObject = TestDataParcelable("Hello") + intent.putExtra(key, testObject) + val retrievedObject = intent.getParcelableArgument(key, TestDataParcelable::class.java) + assertEquals(testObject, retrievedObject) + } + + @Test + fun test_get_serializable_argument_when_given_null_intent_should_return_null() { + val retrievedObject = (null as Intent?).getSerializableArgument(key, TestData::class.java) + assertNull(retrievedObject) + } + + @Test + fun test_get_parcelable_argument_when_given_null_intent_should_return_null() { + val retrievedObject = (null as Intent?).getParcelableArgument(key, TestDataParcelable::class.java) + assertNull(retrievedObject) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/extensions/StringExtensionTests.kt b/app/src/androidTest/java/com/nextcloud/extensions/StringExtensionTests.kt new file mode 100644 index 000000000000..c9985b7309fa --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/extensions/StringExtensionTests.kt @@ -0,0 +1,176 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.extensions +import com.nextcloud.utils.extensions.eTagChanged +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test + +@Suppress("TooManyFunctions") +class StringExtensionTests { + @Test + fun testIsNotBlankAndEqualsWhenGivenBothStringsAreNull() { + val str1: String? = null + val str2: String? = null + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenFirstStringIsNull() { + val str1: String? = null + val str2 = "hello" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenSecondStringIsNull() { + val str1 = "hello" + val str2: String? = null + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenBothStringsAreEmpty() { + val str1 = "" + val str2 = "" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenFirstStringIsEmpty() { + val str1 = "" + val str2 = "hello" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenSecondStringIsEmpty() { + val str1 = "hello" + val str2 = "" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenBothStringsAreWhitespaceOnly() { + val str1 = " " + val str2 = " \t " + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenFirstStringIsWhitespaceOnly() { + val str1 = " " + val str2 = "hello" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenSecondStringIsWhitespaceOnly() { + val str1 = "hello" + val str2 = " " + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenStringsAreDifferentButBothValid() { + val str1 = "hello" + val str2 = "world" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenStringsHaveDifferentCase() { + val str1 = "Hello" + val str2 = "hello" + assertFalse(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenMixedCaseStrings() { + val str1 = "HeLLo WoRLd" + val str2 = "hello world" + assertFalse(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenUppercaseStrings() { + val str1 = "HELLO" + val str2 = "hello" + assertFalse(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenBothStringsAreIdenticalAndValid() { + val str1 = "hello" + val str2 = "hello" + assertFalse(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenBothStringsAreIdenticalWithSpaces() { + val str1 = "hello world" + val str2 = "hello world" + assertFalse(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenBothStringsAreIdenticalSingleCharacter() { + val str1 = "a" + val str2 = "A" + assertFalse(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenBothStringsAreIdenticalWithSpecialCharacters() { + val str1 = "hello@world!123" + val str2 = "HELLO@WORLD!123" + assertFalse(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenOneHasLeadingWhitespaceAndOtherDoesNot() { + val str1 = " hello" + val str2 = "HELLO" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenOneHasTrailingWhitespaceAndOtherDoesNot() { + val str1 = "hello" + val str2 = "HELLO " + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenBothHaveIdenticalWhitespacePaddingDifferentCase() { + val str1 = " hello " + val str2 = " HELLO " + assertFalse(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenMixedWhitespaceCharacters() { + val str1 = "\t" + val str2 = "\n" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenOneIsNullAndOtherIsEmpty() { + val str1: String? = null + val str2 = "" + assertTrue(str1.eTagChanged(str2)) + } + + @Test + fun testIsNotBlankAndEqualsWhenGivenOneIsNullAndOtherIsWhitespace() { + val str1: String? = null + val str2 = " " + assertTrue(str1.eTagChanged(str2)) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/sso/InputStreamBinderTest.kt b/app/src/androidTest/java/com/nextcloud/sso/InputStreamBinderTest.kt new file mode 100644 index 000000000000..a7ad3de20764 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/sso/InputStreamBinderTest.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.sso + +import com.nextcloud.android.sso.InputStreamBinder +import com.nextcloud.android.sso.QueryParam +import org.junit.Assert.assertEquals +import org.junit.Test + +class InputStreamBinderTest { + @Test + fun convertMapToNVP() { + val source = mutableMapOf() + source["quality"] = "1024p" + source["someOtherParameter"] = "parameterValue" + source["duplicate"] = "1" + source["duplicate"] = "2" // this overwrites previous parameter + + val output = InputStreamBinder.convertMapToNVP(source) + + assertEquals(source.size, output.size) + assertEquals("1024p", output[0].value) + assertEquals("parameterValue", output[1].value) + assertEquals("2", output[2].value) + } + + @Test + fun convertListToNVP() { + val source = mutableListOf() + source.add(QueryParam("quality", "1024p")) + source.add(QueryParam("someOtherParameter", "parameterValue")) + source.add(QueryParam("duplicate", "1")) + source.add(QueryParam("duplicate", "2")) // here we can have same parameter multiple times + + val output = InputStreamBinder.convertListToNVP(source) + + assertEquals(source.size, output.size) + assertEquals("1024p", output[0].value) + assertEquals("parameterValue", output[1].value) + assertEquals("1", output[2].value) + assertEquals("2", output[3].value) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/strings/StringsTests.kt b/app/src/androidTest/java/com/nextcloud/strings/StringsTests.kt new file mode 100644 index 000000000000..3c939cd37958 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/strings/StringsTests.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.strings + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import com.nextcloud.client.jobs.upload.FileUploadHelper.Companion.MAX_FILE_COUNT +import com.owncloud.android.R +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class StringsTests { + + private lateinit var context: Context + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + @Test + fun testMaxFileCountText() { + val message = context.resources.getQuantityString( + R.plurals.file_upload_limit_message, + MAX_FILE_COUNT, + MAX_FILE_COUNT + ) + + assertEquals(message, "You can upload up to 500 files at once.") + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt b/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt new file mode 100644 index 000000000000..560b94ff6172 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.test + +import com.nextcloud.client.network.Connectivity +import com.nextcloud.client.network.ConnectivityService + +/** A mocked connectivity service returning that the device is offline **/ +class ConnectivityServiceOfflineMock : ConnectivityService { + override fun isNetworkAndServerAvailable(callback: ConnectivityService.GenericCallback) { + callback.onComplete(false) + } + + override fun isConnected(): Boolean = false + + override fun isInternetWalled(): Boolean = false + + override fun getConnectivity(): Connectivity = Connectivity.CONNECTED_WIFI +} diff --git a/app/src/androidTest/java/com/nextcloud/test/FileDeletionTests.kt b/app/src/androidTest/java/com/nextcloud/test/FileDeletionTests.kt new file mode 100644 index 000000000000..a24c592a12d4 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/FileDeletionTests.kt @@ -0,0 +1,296 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.test + +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeType +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import kotlin.random.Random + +@Suppress("TooManyFunctions", "MagicNumber") +class FileDeletionTests : AbstractIT() { + + private lateinit var tempDir: File + + @Before + fun setup() { + val parent = System.getProperty("java.io.tmpdir") + val childPath = "file_deletion_test_${System.currentTimeMillis()}" + tempDir = File(parent, childPath) + tempDir.mkdirs() + } + + @After + fun cleanup() { + tempDir.deleteRecursively() + } + + private fun getRandomRemoteId(): String = Random + .nextLong(10_000_000L, 99_999_999L) + .toString() + .padEnd(32, '0') + + private fun createAndSaveSingleFileWithLocalCopy(): OCFile { + val now = System.currentTimeMillis() + + val file = OCFile("/TestFile.txt").apply { + fileId = Random.nextLong(1, 10_000) + parentId = 0 + remoteId = getRandomRemoteId() + fileLength = 1024 + mimeType = MimeType.TEXT_PLAIN + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNV" + } + + val localFile = File(tempDir, "TestFile_${file.fileId}.txt").apply { + parentFile?.mkdirs() + createNewFile() + writeText("Temporary test content") + } + file.storagePath = localFile.absolutePath + + storageManager.saveFile(file) + + return file + } + + private fun createAndSaveFolderTree(): OCFile { + val now = System.currentTimeMillis() + val rootFolder = OCFile("/TestFolder").apply { + fileId = Random.nextLong(1, 10_000) + parentId = 0 + remoteId = getRandomRemoteId() + mimeType = MimeType.DIRECTORY + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNVCK" + } + + val subFolder = OCFile("/TestFolder/Sub").apply { + fileId = rootFolder.fileId + 1 + parentId = rootFolder.fileId + remoteId = getRandomRemoteId() + mimeType = MimeType.DIRECTORY + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNVCK" + } + + val file1 = OCFile("/TestFolder/file1.txt").apply { + fileId = rootFolder.fileId + 2 + parentId = rootFolder.fileId + remoteId = getRandomRemoteId() + fileLength = 512 + mimeType = MimeType.TEXT_PLAIN + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNV" + } + + val file2 = OCFile("/TestFolder/Sub/file2.txt").apply { + fileId = rootFolder.fileId + 3 + parentId = subFolder.fileId + remoteId = getRandomRemoteId() + fileLength = 256 + mimeType = MimeType.TEXT_PLAIN + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNV" + } + + listOf(rootFolder, subFolder, file1, file2).forEach { storageManager.saveFile(it) } + + val file1Path = File(tempDir, "file1_${file1.fileId}.txt").apply { createNewFile() } + val file2Path = File(tempDir, "file2_${file2.fileId}.txt").apply { createNewFile() } + + file1.storagePath = file1Path.absolutePath + file2.storagePath = file2Path.absolutePath + + storageManager.saveFile(file1) + storageManager.saveFile(file2) + + return rootFolder + } + + private fun getMixedOcFiles(): List { + val now = System.currentTimeMillis() + + fun createFolder(id: Long, parentId: Long, path: String): OCFile = OCFile(path).apply { + fileId = id + this.parentId = parentId + remoteId = getRandomRemoteId() + mimeType = MimeType.DIRECTORY + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNVCK" + } + + fun createFile(id: Long, parentId: Long, path: String, size: Long, mime: String): OCFile = OCFile(path).apply { + fileId = id + this.parentId = parentId + remoteId = getRandomRemoteId() + fileLength = size + creationTimestamp = now + mimeType = mime + modificationTimestamp = now + permissions = "RWDNV" + } + + val list = mutableListOf() + + list.add(createFolder(1, 0, "/")) + + list.add(createFolder(5, 2, "/Documents/Projects")) + list.add(createFile(9, 5, "/Documents/Projects/spec.txt", 12000, MimeType.TEXT_PLAIN)) + list.add(createFolder(2, 1, "/Documents")) + list.add(createFile(11, 7, "/Photos/Vacation/img2.jpg", 300000, MimeType.JPEG)) + list.add(createFolder(7, 3, "/Photos/Vacation")) + list.add(createFile(4, 2, "/Documents/example.pdf", 150000, MimeType.PDF)) + list.add(createFolder(3, 1, "/Photos")) + list.add(createFile(12, 3, "/Photos/cover.png", 80000, MimeType.PNG)) + list.add(createFile(6, 5, "/Documents/Projects/readme.txt", 2000, MimeType.TEXT_PLAIN)) + list.add(createFolder(8, 5, "/Documents/Projects/Archive")) + list.add(createFile(13, 8, "/Documents/Projects/Archive/old.bmp", 900000, MimeType.BMP)) + list.add(createFile(10, 7, "/Photos/Vacation/img1.jpg", 250000, MimeType.JPEG)) + list.add(createFolder(14, 1, "/Temp")) + list.add(createFile(15, 14, "/Temp/tmp_file_1.txt", 400, MimeType.TEXT_PLAIN)) + list.add(createFile(16, 14, "/Temp/tmp_file_2.txt", 800, MimeType.TEXT_PLAIN)) + list.add(createFolder(17, 14, "/Temp/Nested")) + list.add(createFile(18, 17, "/Temp/Nested/deep.txt", 100, MimeType.TEXT_PLAIN)) + list.add(createFile(19, 2, "/Documents/notes.txt", 1500, MimeType.TEXT_PLAIN)) + list.add(createFolder(20, 3, "/Photos/EmptyFolder")) + + list.forEach { ocFile -> + if (!ocFile.isFolder) { + val localFile = File(tempDir, ocFile.remoteId).apply { + parentFile?.mkdirs() + createNewFile() + writeText("test content") + } + ocFile.storagePath = localFile.absolutePath + storageManager.saveFile(ocFile) + } else { + // For folders, create the folder in tempDir + val localFolder = File(tempDir, ocFile.remoteId).apply { mkdirs() } + ocFile.storagePath = localFolder.absolutePath + storageManager.saveFile(ocFile) + } + } + + return list + } + + @Test + fun deleteMixedFiles() { + var result = false + val files = getMixedOcFiles() + + files.forEach { + result = storageManager.removeFile(it, true, true) + if (!result) { + fail("remove operation is failed") + } + } + + assert(result) + } + + @Test + fun removeNullFileShouldReturnsFalse() { + val result = storageManager.removeFile(null, true, true) + assertFalse(result) + } + + @Test + fun deleteFileOnlyFromDb() { + val file = createAndSaveSingleFileWithLocalCopy() + + val result = storageManager.removeFile(file, true, false) + + assertTrue(result) + + // verify DB no longer contains file + val fromDb = storageManager.getFileById(file.fileId) + assertNull(fromDb) + + // verify local file still exists + assertTrue(File(file.storagePath).exists()) + } + + @Test + fun deleteFileOnlyLocalCopy() { + val file = createAndSaveSingleFileWithLocalCopy() + + val result = storageManager.removeFile(file, false, true) + + assertTrue(result) + + // DB should still contain file + val fromDb = storageManager.getFileById(file.fileId) + assertNotNull(fromDb) + + // Storage path should be null + assertNull(fromDb?.storagePath) + } + + @Test + fun deleteFileDBAndLocal() { + val file = createAndSaveSingleFileWithLocalCopy() + + val result = storageManager.removeFile(file, true, true) + + assertTrue(result) + + assertNull(storageManager.getFileById(file.fileId)) + assertFalse(File(file.storagePath).exists()) + } + + @Test + fun deleteFolderRecursive() { + val folder = createAndSaveFolderTree() + + val result = storageManager.removeFile(folder, true, true) + + assertTrue(result) + + // Folder removed from DB + assertNull(storageManager.getFileById(folder.fileId)) + + // subdirectories and files are removed + val children = storageManager.getAllFilesRecursivelyInsideFolder(folder) + assertTrue(children.isEmpty()) + + // local folder removed + val localPath = FileStorageUtils.getDefaultSavePathFor(user.accountName, folder) + assertFalse(File(localPath).exists()) + } + + @Test + fun removeFolderFileIdMinusOneSkipsDBDeletion() { + val folder = OCFile("/Test").apply { + fileId = -1 + mimeType = MimeType.DIRECTORY + } + + val result = storageManager.removeFile(folder, true, false) + + assertTrue(result) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/FileRemovedIdlingResource.kt b/app/src/androidTest/java/com/nextcloud/test/FileRemovedIdlingResource.kt new file mode 100644 index 000000000000..8d52cc303047 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/FileRemovedIdlingResource.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.test + +import androidx.test.espresso.IdlingResource +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +/** + * IdlingResource that can be reused to watch the removal of different file ids sequentially. + * + * Use setFileId(fileId) before triggering the deletion. The resource will call the Espresso callback + * once the file no longer exists. Call unregister from IdlingRegistry in @After. + */ +class FileRemovedIdlingResource(private val storageManager: FileDataStorageManager) : IdlingResource { + private var resourceCallback: IdlingResource.ResourceCallback? = null + + // null means "no file set" + private var currentFile = AtomicReference(null) + + override fun getName(): String = "${this::class.java.simpleName}" + + override fun isIdleNow(): Boolean { + val file = currentFile.get() + // If no file set, consider idle. If file set, idle only if it doesn't exist. + val idle = file == null || (!storageManager.fileExists(file.fileId) && !file.exists()) + if (idle && file != null) { + // if we detect it's already removed, notify and clear + resourceCallback?.onTransitionToIdle() + currentFile.set(null) + } + return idle + } + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) { + this.resourceCallback = callback + } + + /** + * Start watching the given file. Call this right before performing the UI action that triggers deletion. + */ + fun setFile(file: OCFile) { + currentFile.set(file) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/GrantStoragePermissionRule.kt b/app/src/androidTest/java/com/nextcloud/test/GrantStoragePermissionRule.kt new file mode 100644 index 000000000000..6bc4cd750e18 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/GrantStoragePermissionRule.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Álvaro Brey + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test + +import android.Manifest +import android.os.Build +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Rule to automatically enable the test to write to the external storage. + * Depending on the SDK version, different approaches might be necessary to achieve the full access. + */ +class GrantStoragePermissionRule private constructor() { + + companion object { + @JvmStatic + fun grant(): TestRule = when { + Build.VERSION.SDK_INT < Build.VERSION_CODES.R -> GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + + else -> GrantManageExternalStoragePermissionRule() + } + } + + private class GrantManageExternalStoragePermissionRule : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + // Refer to https://developer.android.com/training/data-storage/manage-all-files#enable-manage-external-storage-for-testing + InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand( + "appops set --uid ${InstrumentationRegistry.getInstrumentation().targetContext.packageName} " + + "MANAGE_EXTERNAL_STORAGE allow" + ) + base.evaluate() + } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/InjectionOverrideRule.kt b/app/src/androidTest/java/com/nextcloud/test/InjectionOverrideRule.kt new file mode 100644 index 000000000000..0bb023f4376d --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/InjectionOverrideRule.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test + +import android.app.Instrumentation +import androidx.test.platform.app.InstrumentationRegistry +import dagger.android.AndroidInjector +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class InjectionOverrideRule(private val overrideInjectors: Map, AndroidInjector<*>>) : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() + val testApp = instrumentation.targetContext.applicationContext as TestMainApp + overrideInjectors.entries.forEach { + testApp.addTestInjector(it.key, it.value) + } + base.evaluate() + testApp.clearTestInjectors() + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/InjectionTestActivityTest.kt b/app/src/androidTest/java/com/nextcloud/test/InjectionTestActivityTest.kt new file mode 100644 index 000000000000..76a3d19efee6 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/InjectionTestActivityTest.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.R +import dagger.android.AndroidInjector +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test + +class InjectionTestActivityTest { + + @get:Rule + val injectionOverrideRule = + InjectionOverrideRule( + mapOf( + InjectionTestActivity::class.java to AndroidInjector { activity -> + val appPreferencesMock = mockk() + every { appPreferencesMock.lastUploadPath } returns INJECTED_STRING + activity.appPreferences = appPreferencesMock + } + ) + ) + + @Test + fun testInjectionOverride() { + launchActivity().use { _ -> + onView(withId(R.id.text)).check(matches(withText(INJECTED_STRING))) + } + } + + companion object { + private const val INJECTED_STRING = "injected string" + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/LoopFailureHandler.kt b/app/src/androidTest/java/com/nextcloud/test/LoopFailureHandler.kt new file mode 100644 index 000000000000..48baf2cec9b0 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/LoopFailureHandler.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.test + +import android.content.Context +import android.view.View +import androidx.test.espresso.FailureHandler +import androidx.test.espresso.base.DefaultFailureHandler +import org.hamcrest.Matcher + +/** + * When testing inside of a loop, test failures are hard to attribute. For that, wrap them in an outer + * exception detailing more about the context. + * + * Set the failure handler via + * ``` + * Espresso.setFailureHandler( + * LoopFailureHandler(targetContext, "Test failed in iteration $yourTestIterationCounter") + * ) + * ``` + * and set it back to the default afterwards via + * ``` + * Espresso.setFailureHandler(DefaultFailureHandler(targetContext)) + * ``` + */ +class LoopFailureHandler(targetContext: Context, private val loopMessage: String) : FailureHandler { + private val delegate: FailureHandler = DefaultFailureHandler(targetContext) + + override fun handle(error: Throwable?, viewMatcher: Matcher?) { + // Wrap in additional Exception + delegate.handle(Exception(loopMessage, error), viewMatcher) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/NextcloudViewMatchers.kt b/app/src/androidTest/java/com/nextcloud/test/NextcloudViewMatchers.kt new file mode 100644 index 000000000000..11fa50fb90b0 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/NextcloudViewMatchers.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.test + +import android.view.View +import android.widget.TextView +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +fun withSelectedText(expected: String): Matcher = object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("with selected text \"$expected\"") + } + + @Suppress("ReturnCount") + override fun matchesSafely(view: View): Boolean { + if (view !is TextView) return false + val text = view.text?.toString() ?: "" + val s = view.selectionStart + val e = view.selectionEnd + @Suppress("ComplexCondition") + if (s < 0 || e < 0 || s > e || e > text.length) return false + return text.substring(s, e) == expected + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/RandomStringGenerator.kt b/app/src/androidTest/java/com/nextcloud/test/RandomStringGenerator.kt new file mode 100644 index 000000000000..44a55e390bc3 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/RandomStringGenerator.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test + +object RandomStringGenerator { + private const val DEFAULT_LENGTH = 8 + private val ALLOWED_CHARACTERS = ('A'..'Z') + ('a'..'z') + ('0'..'9') + + @JvmOverloads + @JvmStatic + fun make(length: Int = DEFAULT_LENGTH): String = (1..length) + .map { ALLOWED_CHARACTERS.random() } + .joinToString("") +} diff --git a/app/src/androidTest/java/com/nextcloud/test/RetryTestRule.kt b/app/src/androidTest/java/com/nextcloud/test/RetryTestRule.kt new file mode 100644 index 000000000000..45506db38800 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/RetryTestRule.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test + +import com.owncloud.android.BuildConfig +import com.owncloud.android.lib.common.utils.Log_OC +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * C&p from https://stackoverflow.com/questions/45635833/how-can-i-use-flakytest-annotation-now on 18.03.2020 + */ +class RetryTestRule(val retryCount: Int = defaultRetryValue) : TestRule { + + companion object { + private val TAG = RetryTestRule::class.java.simpleName + + @Suppress("MagicNumber") + private val defaultRetryValue: Int = if (BuildConfig.CI) 5 else 1 + } + + override fun apply(base: Statement, description: Description): Statement = statement(base, description) + + @Suppress("TooGenericExceptionCaught") // and this exactly what we want here + private fun statement(base: Statement, description: Description): Statement { + return object : Statement() { + + override fun evaluate() { + Log_OC.d(TAG, "Evaluating ${description.methodName}") + + var caughtThrowable: Throwable? = null + + for (i in 0 until retryCount) { + try { + base.evaluate() + return + } catch (t: Throwable) { + caughtThrowable = t + Log_OC.e(TAG, description.methodName + ": run " + (i + 1) + " failed") + } + } + + Log_OC.e(TAG, description.methodName + ": giving up after " + retryCount + " failures") + if (caughtThrowable != null) { + throw caughtThrowable + } + } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/TestMainApp.kt b/app/src/androidTest/java/com/nextcloud/test/TestMainApp.kt new file mode 100644 index 000000000000..912c7cd95b52 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/TestMainApp.kt @@ -0,0 +1,59 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test + +import com.owncloud.android.MainApp +import com.owncloud.android.lib.common.utils.Log_OC +import dagger.android.AndroidInjector +import dagger.android.DispatchingAndroidInjector + +/** + * The purpose of this class is to allow overriding injections in Android classes (which use parameter injection instead + * of constructor injection). + * + * To automate its usage, pair with [InjectionOverrideRule]; or call [addTestInjector] manually for more control. + */ +class TestMainApp : MainApp() { + + val foo = "BAR" + private var overrideInjectors: MutableMap, AndroidInjector<*>> = mutableMapOf() + + /** + * If you call this before a test please remember to call [clearTestInjectors] afterwards + */ + fun addTestInjector(clazz: Class<*>, injector: AndroidInjector<*>) { + Log_OC.d(TAG, "addTestInjector: added injector for $clazz") + overrideInjectors[clazz] = injector + } + + fun clearTestInjectors() { + overrideInjectors.clear() + } + + override fun androidInjector(): AndroidInjector { + @Suppress("UNCHECKED_CAST") + return InjectorWrapper(dispatchingAndroidInjector, overrideInjectors as Map, AndroidInjector>) + } + + class InjectorWrapper( + private val baseInjector: DispatchingAndroidInjector, + private val overrideInjectors: Map, AndroidInjector> + ) : AndroidInjector { + override fun inject(instance: Any) { + baseInjector.inject(instance) + overrideInjectors[instance.javaClass]?.let { customInjector -> + Log_OC.d(TAG, "Injecting ${instance.javaClass} with ${customInjector.javaClass}") + customInjector.inject(instance) + } + } + } + + companion object { + private const val TAG = "TestMainApp" + } +} diff --git a/app/src/androidTest/java/com/nextcloud/test/model/TestModels.kt b/app/src/androidTest/java/com/nextcloud/test/model/TestModels.kt new file mode 100644 index 000000000000..37dcd1d844c9 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/model/TestModels.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test.model + +import android.os.Parcel +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.io.Serializable + +@Parcelize +class OtherTestData : Parcelable + +data class TestData(val message: String) : Serializable + +data class TestDataParcelable(val message: String) : Parcelable { + constructor(parcel: Parcel) : this(parcel.readString() ?: "") + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(message) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): TestDataParcelable = TestDataParcelable(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/ui/BitmapIT.kt b/app/src/androidTest/java/com/nextcloud/ui/BitmapIT.kt new file mode 100644 index 000000000000..2d841e299c7f --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/ui/BitmapIT.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.graphics.BitmapFactory +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class BitmapIT : AbstractIT() { + private val testClassName = "com.nextcloud.ui.BitmapIT" + + @Test + @ScreenshotTest + fun roundBitmap() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + val file = getFile("christine.jpg") + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + + val imageView = ImageView(activity).apply { + setImageBitmap(bitmap) + } + + val bitmap2 = BitmapFactory.decodeFile(file.absolutePath) + val imageView2 = ImageView(activity).apply { + setImageBitmap(BitmapUtils.roundBitmap(bitmap2)) + } + + val linearLayout = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(context.getColor(R.color.grey_200)) + } + linearLayout.addView(imageView, 200, 200) + linearLayout.addView(imageView2, 200, 200) + activity.addView(linearLayout) + } + + val screenShotName = createName(testClassName + "_" + "roundBitmap", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { activity -> + screenshotViaName(activity, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/ui/SetOnlineStatusBottomSheetIT.kt b/app/src/androidTest/java/com/nextcloud/ui/SetOnlineStatusBottomSheetIT.kt new file mode 100644 index 000000000000..c9e07da94cfd --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/ui/SetOnlineStatusBottomSheetIT.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.Manifest +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.rule.GrantPermissionRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.lib.resources.users.StatusType +import com.owncloud.android.ui.activity.FileDisplayActivity +import org.junit.Rule +import org.junit.Test + +class SetOnlineStatusBottomSheetIT : AbstractIT() { + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.POST_NOTIFICATIONS + ) + + @Test + fun open() { + launchActivity().use { scenario -> + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { activity -> + val sut = SetOnlineStatusBottomSheet( + Status(StatusType.DND, "Working hard…", "🤖", -1) + ) + sut.show(activity.supportFragmentManager, "") + } + onView(withId(R.id.onlineStatus)) + .check(matches(isDisplayed())) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/ui/SetStatusMessageBottomSheetIT.kt b/app/src/androidTest/java/com/nextcloud/ui/SetStatusMessageBottomSheetIT.kt new file mode 100644 index 000000000000..b9e9feec6e2e --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/ui/SetStatusMessageBottomSheetIT.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.Manifest +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.rule.GrantPermissionRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.lib.resources.users.ClearAt +import com.owncloud.android.lib.resources.users.PredefinedStatus +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.lib.resources.users.StatusType +import com.owncloud.android.ui.activity.FileDisplayActivity +import org.junit.Rule +import org.junit.Test + +class SetStatusMessageBottomSheetIT : AbstractIT() { + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.POST_NOTIFICATIONS + ) + + @Test + fun open() { + launchActivity().use { scenario -> + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { activity -> + val sut = SetStatusMessageBottomSheet( + user, + Status(StatusType.DND, "Working hard…", "🤖", -1) + ) + val predefinedStatus: ArrayList = arrayListOf( + PredefinedStatus("meeting", "📅", "In a meeting", ClearAt("period", "3600")), + PredefinedStatus("commuting", "🚌", "Commuting", ClearAt("period", "1800")), + PredefinedStatus("be-right-back", "⏳", "Be right back", ClearAt("period", "900")), + PredefinedStatus("remote-work", "🏡", "Working remotely", ClearAt("end-of", "day")), + PredefinedStatus("sick-leave", "🤒", "Out sick", ClearAt("end-of", "day")), + PredefinedStatus("vacationing", "🌴", "Vacationing", null) + ) + sut.setPredefinedStatus(predefinedStatus) + sut.show(activity.supportFragmentManager, "") + } + + onView(withId(R.id.predefinedStatusList)) + .check(matches(isDisplayed())) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/AutoRenameTests.kt b/app/src/androidTest/java/com/nextcloud/utils/AutoRenameTests.kt new file mode 100644 index 000000000000..bc8f39c0290e --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/AutoRenameTests.kt @@ -0,0 +1,312 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Philipp Hasper + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.nextcloud.utils.autoRename.AutoRename +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OCCapability +import org.junit.Before +import org.junit.Test + +@Suppress("TooManyFunctions") +class AutoRenameTests : AbstractOnServerIT() { + + private var capability: OCCapability = fileDataStorageManager.getCapability(account.name) + private val forbiddenFilenameExtension = "." + private val forbiddenFilenameCharacter = ">" + + @Before + fun setup() { + testOnlyOnServer(NextcloudVersion.nextcloud_30) + + capability = capability.apply { + isWCFEnabled = CapabilityBooleanType.TRUE + forbiddenFilenameExtensionJson = listOf( + """[" ",".",".part",".part"]""", + """[".",".part",".part"," "]""", + """[".",".part"," ", ".part"]""", + """[".part"," ", ".part","."]""", + """[" ",".",".PART",".PART"]""", + """[".",".PART",".PART"," "]""", + """[".",".PART"," ", ".PART"]""", + """[".PART"," ", ".PART","."]""" + ).random() + forbiddenFilenameCharactersJson = """["<", ">", ":", "\\\\", "/", "|", "?", "*", "&"]""" + } + } + + @Test + fun testInvalidChar() { + val filename = "file${forbiddenFilenameCharacter}file.txt" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "file_file.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testInvalidExtension() { + val filename = "file$forbiddenFilenameExtension" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "file_" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testMultipleInvalidChars() { + val filename = "file|name?<>.txt" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "file_name___.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testStartEndInvalidExtensions() { + val filename = " .file.part " + val result = AutoRename.rename(filename, capability) + val expectedFilename = "_file_part" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testStartInvalidExtension() { + val filename = " .file.part" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "_file_part" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testEndInvalidExtension() { + val filename = ".file.part " + val result = AutoRename.rename(filename, capability) + val expectedFilename = "_file_part" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testMiddleNonPrintableChar() { + val filename = "file\u0001name.txt" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "filename.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testStartNonPrintableChar() { + val filename = "\u0001filename.txt" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "filename.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testEndNonPrintableChar() { + val filename = "filename.txt\u0001" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "filename.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testExtensionNonPrintableChar() { + val filename = "filename.t\u0001xt" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "filename.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testMiddleInvalidFolderChar() { + val folderPath = "abc/def/kg$forbiddenFilenameCharacter/lmo/pp/" + val result = AutoRename.rename(folderPath, capability) + val expectedFolderName = "abc/def/kg_/lmo/pp/" + assert(result == expectedFolderName) { "Expected $expectedFolderName but got $result" } + } + + @Test + fun testEndInvalidFolderChar() { + val folderPath = "abc/def/kg/lmo/pp$forbiddenFilenameCharacter/" + val result = AutoRename.rename(folderPath, capability) + val expectedFolderName = "abc/def/kg/lmo/pp_/" + assert(result == expectedFolderName) { "Expected $expectedFolderName but got $result" } + } + + @Test + fun testStartInvalidFolderChar() { + val folderPath = "${forbiddenFilenameCharacter}abc/def/kg/lmo/pp/" + val result = AutoRename.rename(folderPath, capability) + val expectedFolderName = "_abc/def/kg/lmo/pp/" + assert(result == expectedFolderName) { "Expected $expectedFolderName but got $result" } + } + + @Test + fun testMixedInvalidChar() { + val filename = " file\u0001na${forbiddenFilenameCharacter}me.txt " + val result = AutoRename.rename(filename, capability) + val expectedFilename = "filena_me.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testStartsWithPathSeparator() { + val folderPath = "/abc/def/kg/lmo/pp$forbiddenFilenameCharacter/file.txt/" + val result = AutoRename.rename(folderPath, capability) + val expectedFolderName = "/abc/def/kg/lmo/pp_/file.txt/" + assert(result == expectedFolderName) { "Expected $expectedFolderName but got $result" } + } + + @Test + fun testStartsWithPathSeparatorAndValidFilepath() { + val folderPath = "/COm02/2569.webp/" + val result = AutoRename.rename(folderPath, capability) + val expectedFolderName = "/COm02/2569.webp/" + assert(result == expectedFolderName) { "Expected $expectedFolderName but got $result" } + } + + @Test + fun testValidFilename() { + val filename = ".file.TXT" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "_file.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testRenameExtensionForFolder() { + val filename = "/Pictures/@User/SubDir/08.16.07 Ka Yel/" + val result = AutoRename.rename(filename, capability) + assert(result == filename) { "Expected $filename but got $result" } + } + + @Test + fun testRenameExtensionForFile() { + val filename = "/Pictures/@User/SubDir/08.16.07 Ka Yel.TXT" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "/Pictures/@User/SubDir/08.16.07 Ka Yel.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testE2EEFile() { + val decryptedFile = DecryptedFile( + authenticationTag = "HQlWBdm+gYC5kZwWnqXR1Q==", + filename = "a:a.jpg", + nonce = "sigyys8SfPZSScDJ860vYw==", + mimetype = "image/jpeg", + key = "sigyys8SfPZSScDJ860vYw==" + ) + + val result = AutoRename.rename(decryptedFile.filename, capability) + val expectedFilename = "a_a.jpg" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testRemovingLeadingWhitespace() { + val filename = " readme.txt" + val result = AutoRename.rename(filename, capability) + val expectedFilename = "readme.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testRemovingTrailingWhitespace() { + val filename = "readme.txt " + val result = AutoRename.rename(filename, capability) + val expectedFilename = "readme.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testRemovingTrailingAndLeadingWhitespace() { + val filename = " readme.txt " + val result = AutoRename.rename(filename, capability) + val expectedFilename = "readme.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testFolderNameLowercase() { + val filename = "Foo.Bar.Baz" + val result = AutoRename.rename(filename, capability, isFolderPath = true) + val expectedFilename = "Foo.Bar.Baz" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + /** + * For documentation see [com.nextcloud.utils.extensions.checkWCFRestrictions] + */ + @Test + fun testWCFDisabledOnNextcloud32ShouldSkipRestrictions() { + val filename = " readme.txt " + val nc32Capability = capability.apply { + versionMayor = NextcloudVersion.nextcloud_32.majorVersionNumber + isWCFEnabled = CapabilityBooleanType.FALSE + } + val result = AutoRename.rename(filename, nc32Capability, isFolderPath = true) + assert(result == filename) { "Expected $filename but got $result" } + } + + @Test + fun testWCFEnabledOnNextcloud32ShouldApplyRestrictions() { + val filename = " readme.txt " + val nc32Capability = capability.apply { + versionMayor = NextcloudVersion.nextcloud_32.majorVersionNumber + isWCFEnabled = CapabilityBooleanType.TRUE + } + val result = AutoRename.rename(filename, nc32Capability, isFolderPath = true) + val expectedFilename = "readme.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testWCFDisabledOnNextcloud30to31ShouldStillApplyRestrictions() { + val filename = " readme.txt " + val nc30Capability = capability.apply { + versionMayor = NextcloudVersion.nextcloud_30.majorVersionNumber + isWCFEnabled = CapabilityBooleanType.FALSE + } + val result = AutoRename.rename(filename, nc30Capability, isFolderPath = true) + val expectedFilename = "readme.txt" + assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } + } + + @Test + fun testWCFOnNextcloudBelow30ShouldSkipRestrictions() { + val filename = " readme.txt " + val nc29Capability = capability.apply { + versionMayor = NextcloudVersion.nextcloud_29.majorVersionNumber + isWCFEnabled = CapabilityBooleanType.TRUE + } + val result = AutoRename.rename(filename, nc29Capability, isFolderPath = true) + assert(result == filename) { "Expected $filename but got $result" } + } + + @Test + fun testRenameWithLowercasedFiles() { + val filename = "1.txt" + val result = AutoRename.rename(filename, capability) + assert(result == filename) { "Expected $filename but got $result" } + + val secondFilename = "/1.txt" + val secondResult = AutoRename.rename(secondFilename, capability) + assert(secondResult == secondFilename) { "Expected $secondFilename but got $secondResult" } + + val thirdFilename = "/A/1.txt" + val thirdResult = AutoRename.rename(thirdFilename, capability) + assert(thirdResult == thirdFilename) { "Expected $thirdFilename but got $thirdResult" } + + val path = "/A/BB/" + val pathResult = AutoRename.rename(path, capability) + assert(pathResult == path) { "Expected $path but got $pathResult" } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/CertificateValidatorTests.kt b/app/src/androidTest/java/com/nextcloud/utils/CertificateValidatorTests.kt new file mode 100644 index 000000000000..127858586a22 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/CertificateValidatorTests.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.gson.Gson +import com.owncloud.android.datamodel.Credentials +import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.InputStreamReader + +class CertificateValidatorTests { + + private var sut: CertificateValidator? = null + + @Before + fun setup() { + sut = CertificateValidator() + } + + @After + fun destroy() { + sut = null + } + + @Test + fun testValidateWhenGivenValidServerKeyAndCertificateShouldReturnTrue() { + val inputStream = + InstrumentationRegistry.getInstrumentation().context.assets.open("credentials.json") + + val credentials = InputStreamReader(inputStream).use { reader -> + Gson().fromJson(reader, Credentials::class.java) + } + + val isCertificateValid = sut?.validate(credentials.publicKey, credentials.certificate) ?: false + assert(isCertificateValid) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/CheckWCFRestrictionsTests.kt b/app/src/androidTest/java/com/nextcloud/utils/CheckWCFRestrictionsTests.kt new file mode 100644 index 000000000000..c16e705c6b49 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/CheckWCFRestrictionsTests.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils + +import com.nextcloud.utils.extensions.checkWCFRestrictions +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OCCapability +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@Suppress("MagicNumber") +class CheckWCFRestrictionsTests { + + private fun createCapability( + version: NextcloudVersion, + isWCFEnabled: CapabilityBooleanType = CapabilityBooleanType.UNKNOWN + ): OCCapability = OCCapability().apply { + this.versionMayor = version.majorVersionNumber + this.isWCFEnabled = isWCFEnabled + } + + @Test + fun testReturnsFalseForVersionsOlderThan30() { + val capability = createCapability(NextcloudVersion.nextcloud_29) + assertFalse(capability.checkWCFRestrictions()) + } + + @Test + fun testReturnsTrueForVersion30WhenWCFAlwaysEnabled() { + val capability = createCapability(NextcloudVersion.nextcloud_30) + assertTrue(capability.checkWCFRestrictions()) + } + + @Test + fun testReturnsTrueForVersion31WhenWCFAlwaysEnabled() { + val capability = createCapability(NextcloudVersion.nextcloud_31) + assertTrue(capability.checkWCFRestrictions()) + } + + @Test + fun testReturnsTrueForVersion32WhenWCFEnabled() { + val capability = createCapability(NextcloudVersion.nextcloud_32, CapabilityBooleanType.TRUE) + assertTrue(capability.checkWCFRestrictions()) + } + + @Test + fun testReturnsFalseForVersion32WhenWCFDisabled() { + val capability = createCapability(NextcloudVersion.nextcloud_32, CapabilityBooleanType.FALSE) + assertFalse(capability.checkWCFRestrictions()) + } + + @Test + fun testReturnsFalseForVersion32WhenWCFIsUnknown() { + val capability = createCapability(NextcloudVersion.nextcloud_32) + assertFalse(capability.checkWCFRestrictions()) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/FileExtensionTest.kt b/app/src/androidTest/java/com/nextcloud/utils/FileExtensionTest.kt new file mode 100644 index 000000000000..2c6a454d0d00 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/FileExtensionTest.kt @@ -0,0 +1,117 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.nextcloud.utils.fileNameValidator.FileNameValidator.isExtensionChanged +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test + +@Suppress("TooManyFunctions") +class FileExtensionTest { + + @Test + fun sameExtensionReturnsFalse() { + assertFalse(isExtensionChanged("file.txt", "other.txt")) + } + + @Test + fun differentExtensionReturnsTrue() { + assertTrue(isExtensionChanged("file.txt", "file.pdf")) + } + + @Test + fun caseDifferenceDoesNotTriggerChange() { + assertFalse(isExtensionChanged("file.JPG", "file.jpg")) + } + + @Test + fun bothWithoutExtensionReturnsFalse() { + assertFalse(isExtensionChanged("README", "LICENSE")) + } + + @Test + fun noExtensionToExtensionReturnsTrue() { + assertTrue(isExtensionChanged("README", "file.txt")) + } + + @Test + fun extensionToNoExtensionReturnsTrue() { + assertTrue(isExtensionChanged("file.txt", "README")) + } + + @Test + fun hiddenFilesWithoutExtensionReturnFalse() { + assertFalse(isExtensionChanged(".gitignore", ".env")) + } + + @Test + fun hiddenFileToNormalExtensionReturnsTrue() { + assertTrue(isExtensionChanged(".gitignore", "file.txt")) + } + + @Test + fun multipleDotsSameLastExtensionReturnsFalse() { + assertFalse(isExtensionChanged("archive.tar.gz", "backup.gz")) + } + + @Test + fun multipleDotsDifferentLastExtensionReturnsTrue() { + assertTrue(isExtensionChanged("archive.tar.gz", "archive.tar.zip")) + } + + @Test + fun trailingDotTreatedAsNoExtensionReturnsTrue() { + assertTrue(isExtensionChanged("file.", "file.txt")) + } + + @Test + fun bothTrailingDotReturnFalse() { + assertFalse(isExtensionChanged("file.", "another.")) + } + + @Test + fun emptyStringsReturnFalse() { + assertFalse(isExtensionChanged("", "")) + } + + @Test + fun emptyStringToExtensionReturnsTrue() { + assertTrue(isExtensionChanged("", "file.txt")) + } + + @Test + fun bothNullReturnFalse() { + assertFalse(isExtensionChanged(null, null)) + } + + @Test + fun previousNullNewNotNullReturnsTrue() { + assertTrue(isExtensionChanged(null, "file.txt")) + } + + @Test + fun previousNotNullNewNullReturnsTrue() { + assertTrue(isExtensionChanged("file.txt", null)) + } + + @Test + fun singleDotFilenameReturnsFalse() { + assertFalse(isExtensionChanged(".", ".")) + } + + @Test + fun dotToExtensionReturnsTrue() { + assertTrue(isExtensionChanged(".", "file.txt")) + } + + @Test + fun filenamesEndingWithDotReturnFalse() { + assertFalse(isExtensionChanged("test.", "another.")) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/FileHelperTest.kt b/app/src/androidTest/java/com/nextcloud/utils/FileHelperTest.kt new file mode 100644 index 000000000000..8f013158cc9b --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/FileHelperTest.kt @@ -0,0 +1,204 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +@Suppress("TooManyFunctions") +class FileHelperTest { + + private lateinit var testDirectory: File + + @Before + fun setup() { + testDirectory = Files.createTempDirectory("test").toFile() + } + + @After + fun tearDown() { + testDirectory.deleteRecursively() + } + + @Test + fun testListDirectoryEntriesWhenGivenNullDirectoryShouldReturnEmptyList() { + val result = FileHelper.listDirectoryEntries(null, 0, 10, false) + assertTrue(result.isEmpty()) + } + + @Test + fun testListDirectoryEntriesWhenGivenNonExistentDirectoryShouldReturnEmptyList() { + val nonExistent = File(testDirectory, "does_not_exist") + val result = FileHelper.listDirectoryEntries(nonExistent, 0, 10, false) + assertTrue(result.isEmpty()) + } + + @Test + fun testListDirectoryEntriesWhenGivenFileInsteadOfDirectoryShouldReturnEmptyList() { + val file = File(testDirectory, "test.txt") + file.createNewFile() + val result = FileHelper.listDirectoryEntries(file, 0, 10, false) + assertTrue(result.isEmpty()) + } + + @Test + fun testListDirectoryEntriesWhenGivenEmptyDirectoryShouldReturnEmptyList() { + val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, false) + assertTrue(result.isEmpty()) + } + + @Test + fun testListDirectoryEntriesWhenFetchingFoldersShouldReturnOnlyFolders() { + File(testDirectory, "folder1").mkdir() + File(testDirectory, "folder2").mkdir() + File(testDirectory, "file1.txt").createNewFile() + File(testDirectory, "file2.txt").createNewFile() + + val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, true) + + assertEquals(2, result.size) + assertTrue(result.all { it.isDirectory }) + } + + @Test + fun testListDirectoryEntriesWhenFetchingFilesShouldReturnOnlyFiles() { + File(testDirectory, "folder1").mkdir() + File(testDirectory, "folder2").mkdir() + File(testDirectory, "file1.txt").createNewFile() + File(testDirectory, "file2.txt").createNewFile() + + val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, false) + + assertEquals(2, result.size) + assertTrue(result.all { it.isFile }) + } + + @Test + fun testListDirectoryEntriesWhenStartIndexProvidedShouldSkipCorrectNumberOfItems() { + for (i in 1..5) File(testDirectory, "file$i.txt").createNewFile() + val result = FileHelper.listDirectoryEntries(testDirectory, 2, 10, false) + assertEquals(3, result.size) + } + + @Test + fun testListDirectoryEntriesWhenMaxItemsProvidedShouldLimitResults() { + for (i in 1..10) File(testDirectory, "file$i.txt").createNewFile() + val result = FileHelper.listDirectoryEntries(testDirectory, 0, 5, false) + assertEquals(5, result.size) + } + + @Test + fun testListDirectoryEntriesWhenGivenStartIndexAndMaxItemsShouldReturnCorrectSubset() { + for (i in 1..10) File(testDirectory, "file$i.txt").createNewFile() + val result = FileHelper.listDirectoryEntries(testDirectory, 3, 4, false) + assertEquals(4, result.size) + } + + @Test + fun testListDirectoryEntriesWhenStartIndexBeyondAvailableShouldReturnEmptyList() { + for (i in 1..3) File(testDirectory, "file$i.txt").createNewFile() + val result = FileHelper.listDirectoryEntries(testDirectory, 10, 5, false) + assertTrue(result.isEmpty()) + } + + @Test + fun testListDirectoryEntriesWhenMaxItemsBeyondAvailableShouldReturnAllItems() { + for (i in 1..3) File(testDirectory, "file$i.txt").createNewFile() + val result = FileHelper.listDirectoryEntries(testDirectory, 0, 100, false) + assertEquals(3, result.size) + } + + @Test + fun testListDirectoryEntriesWhenFetchingFoldersWithOffsetShouldSkipCorrectly() { + for (i in 1..5) File(testDirectory, "folder$i").mkdir() + for (i in 1..3) File(testDirectory, "file$i.txt").createNewFile() + + val result = FileHelper.listDirectoryEntries(testDirectory, 2, 10, true) + + assertEquals(3, result.size) + assertTrue(result.all { it.isDirectory }) + } + + @Test + fun testListDirectoryEntriesWhenFetchingFilesWithOffsetShouldSkipCorrectly() { + for (i in 1..3) File(testDirectory, "folder$i").mkdir() + for (i in 1..5) File(testDirectory, "file$i.txt").createNewFile() + + val result = FileHelper.listDirectoryEntries(testDirectory, 2, 10, false) + + assertEquals(3, result.size) + assertTrue(result.all { it.isFile }) + } + + @Test + fun testListDirectoryEntriesWhenGivenOnlyFoldersAndFetchingFilesShouldReturnEmptyList() { + for (i in 1..5) File(testDirectory, "folder$i").mkdir() + val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, false) + assertTrue(result.isEmpty()) + } + + @Test + fun testListDirectoryEntriesWhenGivenOnlyFilesAndFetchingFoldersShouldReturnEmptyList() { + for (i in 1..5) File(testDirectory, "file$i.txt").createNewFile() + val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, true) + assertTrue(result.isEmpty()) + } + + @Test + fun testListDirectoryEntriesWhenMaxItemsIsZeroShouldReturnEmptyList() { + for (i in 1..5) File(testDirectory, "file$i.txt").createNewFile() + val result = FileHelper.listDirectoryEntries(testDirectory, 0, 0, false) + assertTrue(result.isEmpty()) + } + + @Test + fun testListDirectoryEntriesWhenGivenMixedContentShouldFilterCorrectly() { + for (i in 1..3) File(testDirectory, "folder$i").mkdir() + for (i in 1..7) File(testDirectory, "file$i.txt").createNewFile() + + val folders = FileHelper.listDirectoryEntries(testDirectory, 0, 10, true) + val files = FileHelper.listDirectoryEntries(testDirectory, 0, 10, false) + + assertEquals(3, folders.size) + assertEquals(7, files.size) + assertTrue(folders.all { it.isDirectory }) + assertTrue(files.all { it.isFile }) + } + + @Test + fun testListDirectoryEntriesWhenPaginatingFoldersShouldWorkCorrectly() { + for (i in 1..10) File(testDirectory, "folder$i").mkdir() + + val page1 = FileHelper.listDirectoryEntries(testDirectory, 0, 3, true) + val page2 = FileHelper.listDirectoryEntries(testDirectory, 3, 3, true) + val page3 = FileHelper.listDirectoryEntries(testDirectory, 6, 3, true) + val page4 = FileHelper.listDirectoryEntries(testDirectory, 9, 3, true) + + assertEquals(3, page1.size) + assertEquals(3, page2.size) + assertEquals(3, page3.size) + assertEquals(1, page4.size) + } + + @Test + fun testListDirectoryEntriesWhenPaginatingFilesShouldWorkCorrectly() { + for (i in 1..10) File(testDirectory, "file$i.txt").createNewFile() + + val page1 = FileHelper.listDirectoryEntries(testDirectory, 0, 4, false) + val page2 = FileHelper.listDirectoryEntries(testDirectory, 4, 4, false) + val page3 = FileHelper.listDirectoryEntries(testDirectory, 8, 4, false) + + assertEquals(4, page1.size) + assertEquals(4, page2.size) + assertEquals(2, page3.size) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/FileNameValidatorTests.kt b/app/src/androidTest/java/com/nextcloud/utils/FileNameValidatorTests.kt new file mode 100644 index 000000000000..89d4bb343ff7 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/FileNameValidatorTests.kt @@ -0,0 +1,243 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.nextcloud.utils.fileNameValidator.FileNameValidator +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.R +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OCCapability +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@Suppress("TooManyFunctions") +class FileNameValidatorTests : AbstractOnServerIT() { + + private var capability: OCCapability = fileDataStorageManager.getCapability(account.name) + + @Before + fun setup() { + capability = capability.apply { + isWCFEnabled = CapabilityBooleanType.TRUE + forbiddenFilenamesJson = """[".htaccess",".htaccess"]""" + forbiddenFilenameBaseNamesJson = """ + ["con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4", + "com5", "com6", "com7", "com8", "com9", "com¹", "com²", "com³", + "lpt0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", + "lpt8", "lpt9", "lpt¹", "lpt²", "lpt³"] + """ + forbiddenFilenameExtensionJson = """[" ",".",".part",".part"]""" + forbiddenFilenameCharactersJson = """["<", ">", ":", "\\\\", "/", "|", "?", "*", "&"]""" + } + } + + @Test + fun testInvalidCharacter() { + testOnlyOnServer(NextcloudVersion.nextcloud_30) + + val result = FileNameValidator.checkFileName("file + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.nextcloud.utils.extensions.forbiddenFilenameBaseNames +import com.nextcloud.utils.extensions.forbiddenFilenameCharacters +import com.nextcloud.utils.extensions.forbiddenFilenameExtensions +import com.nextcloud.utils.extensions.forbiddenFilenames +import com.owncloud.android.AbstractIT +import com.owncloud.android.lib.resources.status.OCCapability +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.junit.Test + +@Suppress("MagicNumber", "TooManyFunctions") +class OCCapabilityJsonToListTests : AbstractIT() { + private var capability: OCCapability = fileDataStorageManager.getCapability(account.name) + + // region Valid Input + @Test + fun testForbiddenFilenamesParsedCorrectly() { + capability.forbiddenFilenamesJson = """[".htaccess", ".htaccess"]""" + val result = capability.forbiddenFilenames() + assertEquals(listOf(".htaccess", ".htaccess"), result) + } + + @Test + fun testForbiddenFilenameBaseNamesParsedCorrectly() { + capability.forbiddenFilenameBaseNamesJson = """["con", "prn", "aux"]""" + val result = capability.forbiddenFilenameBaseNames() + assertEquals(listOf("con", "prn", "aux"), result) + } + + @Test + fun testForbiddenFilenameExtensionsParsedCorrectly() { + capability.forbiddenFilenameExtensionJson = """[" ",".",".part"]""" + val result = capability.forbiddenFilenameExtensions() + assertEquals(listOf(" ", ".", ".part"), result) + } + + @Test + fun testForbiddenFilenameCharactersParsedCorrectly() { + capability.forbiddenFilenameCharactersJson = """["<", ">", ":", "\\", "/", "|", "?", "*", "&"]""" + val result = capability.forbiddenFilenameCharacters() + assertEquals(listOf("<", ">", ":", "\\", "/", "|", "?", "*", "&"), result) + } + + @Test + fun testEmptyArrayReturnsEmptyList() { + capability.forbiddenFilenamesJson = """[]""" + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + @Test + fun testSingleElementArray() { + capability.forbiddenFilenamesJson = """[".htaccess"]""" + val result = capability.forbiddenFilenames() + assertEquals(listOf(".htaccess"), result) + } + + @Test + fun testArrayWithWhitespaceAroundJson() { + capability.forbiddenFilenameBaseNamesJson = """ + ["con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4", + "com5", "com6", "com7", "com8", "com9", "com¹", "com²", "com³", + "lpt0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", + "lpt8", "lpt9", "lpt¹", "lpt²", "lpt³"] + """ + val result = capability.forbiddenFilenameBaseNames() + assertEquals(30, result.size) + assertTrue(result.contains("con")) + assertTrue(result.contains("lpt³")) + } + + @Test + fun testUnicodeCharactersPreserved() { + capability.forbiddenFilenameBaseNamesJson = """["com¹", "com²", "com³", "lpt¹", "lpt²", "lpt³"]""" + val result = capability.forbiddenFilenameBaseNames() + assertEquals(listOf("com¹", "com²", "com³", "lpt¹", "lpt²", "lpt³"), result) + } + + @Test + fun testDuplicateEntriesPreserved() { + capability.forbiddenFilenameExtensionJson = """[".part", ".part"]""" + val result = capability.forbiddenFilenameExtensions() + assertEquals(listOf(".part", ".part"), result) + } + // endregion + + // region Null and Blank Input + @Test + fun testNullJsonReturnsEmptyList() { + capability.forbiddenFilenamesJson = null + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + @Test + fun testBlankJsonReturnsEmptyList() { + capability.forbiddenFilenamesJson = " " + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + @Test + fun testEmptyStringJsonReturnsEmptyList() { + capability.forbiddenFilenamesJson = "" + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + // endregion + + // region Malformed Input + @Test + fun testMalformedJsonReturnsEmptyList() { + capability.forbiddenFilenamesJson = """[".htaccess", """ + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + @Test + fun testNonArrayJsonObjectReturnsEmptyList() { + capability.forbiddenFilenamesJson = """{"key": "value"}""" + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + @Test + fun testPlainStringJsonReturnsEmptyList() { + capability.forbiddenFilenamesJson = """.htaccess""" + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + @Test + fun testHtmlErrorPageReturnsEmptyList() { + capability.forbiddenFilenamesJson = "Internal Server Error" + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + @Test + fun testJsonNullLiteralReturnsEmptyList() { + capability.forbiddenFilenamesJson = "null" + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + // endregion + + // region Oversized Input + @Test + fun testOversizedJsonReturnsEmptyList() { + val hugeEntry = "a".repeat(1024) + val entries = Array(600) { """"$hugeEntry"""" } + capability.forbiddenFilenamesJson = "[${entries.joinToString(",")}]" + val result = capability.forbiddenFilenames() + assertEquals(emptyList(), result) + } + + @Test + fun testJsonJustUnderSizeLimitIsParsed() { + val entries = Array(100) { i -> """"entry$i"""" } + capability.forbiddenFilenamesJson = "[${entries.joinToString(",")}]" + val result = capability.forbiddenFilenames() + assertEquals(100, result.size) + assertEquals("entry0", result[0]) + assertEquals("entry99", result[99]) + } + // endregion +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/SharePermissionManagerTest.kt b/app/src/androidTest/java/com/nextcloud/utils/SharePermissionManagerTest.kt new file mode 100644 index 000000000000..3f65ba31269a --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/SharePermissionManagerTest.kt @@ -0,0 +1,272 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.owncloud.android.datamodel.quickPermission.QuickPermissionType +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.lib.resources.shares.extensions.isAllowDownloadAndSyncEnabled +import com.owncloud.android.lib.resources.shares.extensions.toggleAllowDownloadAndSync +import com.owncloud.android.ui.fragment.util.SharePermissionManager +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test + +@Suppress("TooManyFunctions") +class SharePermissionManagerTest { + + private fun createShare(sharePermission: Int, isFolder: Boolean = false, attributesJson: String? = null): OCShare = + if (isFolder) { + OCShare("/test") + .apply { + permissions = sharePermission + attributes = attributesJson + shareType = ShareType.INTERNAL + sharedDate = 1188206955 + shareWith = "User 1" + sharedWithDisplayName = "User 1" + } + } else { + OCShare("/test.png") + .apply { + permissions = sharePermission + attributes = attributesJson + shareType = ShareType.INTERNAL + sharedDate = 1188206955 + shareWith = "User 1" + sharedWithDisplayName = "User 1" + } + }.apply { + this.isFolder = isFolder + } + + // region Permission change tests + @Test + fun testTogglePermissionShouldAddPermissionFlagWhenChecked() { + val initialPermission = OCShare.READ_PERMISSION_FLAG + val updatedPermission = + SharePermissionManager.togglePermission(true, initialPermission, OCShare.UPDATE_PERMISSION_FLAG) + val updatedShare = createShare(updatedPermission) + assertTrue(SharePermissionManager.isCustomPermission(updatedShare)) + } + + @Test + fun testTogglePermissionShouldRemovePermissionFlagWhenUnchecked() { + val initialPermission = OCShare.READ_PERMISSION_FLAG + OCShare.UPDATE_PERMISSION_FLAG + val updatedPermission = + SharePermissionManager.togglePermission(false, initialPermission, OCShare.UPDATE_PERMISSION_FLAG) + val updatedShare = createShare(updatedPermission) + assertTrue(SharePermissionManager.isViewOnly(updatedShare)) + } + // endregion + + // region HasPermissions tests + @Test + fun testHasPermissionShouldReturnTrueIfPermissionPresent() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.UPDATE_PERMISSION_FLAG + assertTrue(SharePermissionManager.hasPermission(permission, OCShare.UPDATE_PERMISSION_FLAG)) + } + + @Test + fun testHasPermissionShouldReturnFalseIfPermissionNotPresent() { + val permission = OCShare.READ_PERMISSION_FLAG + assertFalse(SharePermissionManager.hasPermission(permission, OCShare.UPDATE_PERMISSION_FLAG)) + } + // endregion + + // region Helper Method Tests + @Test + fun testCanEditShouldReturnTrueIfAllPermissionsPresent() { + val share = createShare(OCShare.MAXIMUM_PERMISSIONS_FOR_FOLDER, isFolder = true) + assertTrue(SharePermissionManager.canEdit(share)) + } + + @Test + fun testCanEditShouldReturnFalseIfPermissionsAreInsufficient() { + val share = createShare(OCShare.READ_PERMISSION_FLAG) + assertFalse(SharePermissionManager.canEdit(share)) + } + + @Test + fun testIsViewOnlyShouldReturnTrueIfOnlyReadPermissionSet() { + val share = createShare(OCShare.READ_PERMISSION_FLAG) + assertTrue(SharePermissionManager.isViewOnly(share)) + } + + @Test + fun testIsFileRequestShouldReturnTrueIfOnlyCreatePermissionSetOnFolder() { + val share = createShare(OCShare.CREATE_PERMISSION_FLAG, isFolder = true) + assertTrue(SharePermissionManager.isFileRequest(share)) + } + + @Test + fun testIsFileRequestShouldReturnFalseIfOnlyCreatePermissionSetOnFile() { + val share = createShare(OCShare.CREATE_PERMISSION_FLAG) + assertFalse(SharePermissionManager.isFileRequest(share)) + } + + @Test + fun testIsSecureFileDropShouldReturnTrueIfReadAndCreatePermissionsPresent() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.CREATE_PERMISSION_FLAG + val share = createShare(permission) + assertTrue(SharePermissionManager.isSecureFileDrop(share)) + } + + @Test + fun testCanReshareShouldReturnTrueIfSharePermissionIsPresent() { + val share = createShare(OCShare.SHARE_PERMISSION_FLAG) + assertTrue(SharePermissionManager.canReshare(share)) + } + + @Test + fun testGetMaximumPermissionForFolder() { + assertEquals( + OCShare.MAXIMUM_PERMISSIONS_FOR_FOLDER, + SharePermissionManager.getMaximumPermission(isFolder = true) + ) + } + + @Test + fun testGetMaximumPermissionForFile() { + assertEquals( + OCShare.MAXIMUM_PERMISSIONS_FOR_FILE, + SharePermissionManager.getMaximumPermission(isFolder = false) + ) + } + // endregion + + // region GetSelectedTypeTests + @Test + fun testGetSelectedTypeShouldReturnCanEditWhenFullPermissionsGiven() { + val share = createShare(OCShare.MAXIMUM_PERMISSIONS_FOR_FILE) + assertEquals(QuickPermissionType.CAN_EDIT, SharePermissionManager.getSelectedType(share, encrypted = false)) + } + + @Test + fun testGetSelectedTypeShouldReturnSecureFileDropWhenEncryptedAndReadCreateGiven() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.CREATE_PERMISSION_FLAG + val share = createShare(permission) + assertEquals( + QuickPermissionType.SECURE_FILE_DROP, + SharePermissionManager.getSelectedType(share, encrypted = true) + ) + } + + @Test + fun testGetSelectedTypeShouldReturnFileRequestWhenCreatePermissionGiven() { + val share = createShare(OCShare.CREATE_PERMISSION_FLAG, isFolder = true) + assertEquals(QuickPermissionType.FILE_REQUEST, SharePermissionManager.getSelectedType(share, encrypted = false)) + } + + @Test + fun testGetSelectedTypeShouldReturnViewOnlyWhenReadPermissionGiven() { + val share = createShare(OCShare.READ_PERMISSION_FLAG) + assertEquals(QuickPermissionType.VIEW_ONLY, SharePermissionManager.getSelectedType(share, encrypted = false)) + } + + @Test + fun testGetSelectedTypeShouldReturnCustomPermissionOnlyWhenCustomPermissionGiven() { + val share = createShare(OCShare.READ_PERMISSION_FLAG + OCShare.UPDATE_PERMISSION_FLAG) + assertEquals( + QuickPermissionType.CUSTOM_PERMISSIONS, + SharePermissionManager.getSelectedType(share, encrypted = false) + ) + } + + @Test + fun testGetSelectedTypeShouldReturnNoneOnlyWhenNoPermissionGiven() { + val share = createShare(OCShare.NO_PERMISSION) + assertEquals( + QuickPermissionType.NONE, + SharePermissionManager.getSelectedType(share, encrypted = false) + ) + } + // endregion + + // region CustomPermissions Tests + @Test + fun testIsCustomPermissionShouldReturnFalseWhenNoPermissionsGiven() { + val permission = OCShare.NO_PERMISSION + val share = createShare(permission, isFolder = false) + assertFalse(SharePermissionManager.isCustomPermission(share)) + } + + @Test + fun testIsCustomPermissionShouldReturnFalseWhenNoReadPermissionsGiven() { + val permission = OCShare.SHARE_PERMISSION_FLAG + OCShare.UPDATE_PERMISSION_FLAG + val share = createShare(permission, isFolder = false) + assertFalse(SharePermissionManager.isCustomPermission(share)) + } + + @Test + fun testIsCustomPermissionShouldReturnTrueWhenUpdatePermissionsGivenOnFile() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.UPDATE_PERMISSION_FLAG + val share = createShare(permission, isFolder = false) + assertTrue(SharePermissionManager.isCustomPermission(share)) + } + + @Test + fun testIsCustomPermissionShouldReturnTrueWhenUpdateAndSharePermissionsGivenOnFile() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.UPDATE_PERMISSION_FLAG + OCShare.SHARE_PERMISSION_FLAG + val share = createShare(permission, isFolder = false) + assertTrue(SharePermissionManager.isCustomPermission(share)) + } + + @Test + fun testIsCustomPermissionShouldReturnFalseWhenCreatePermissionsGivenOnFile() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.CREATE_PERMISSION_FLAG + val share = createShare(permission, isFolder = false) + assertFalse(SharePermissionManager.isCustomPermission(share)) + } + + @Test + fun testIsCustomPermissionShouldReturnFalseWhenDeletePermissionsGivenOnFile() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.DELETE_PERMISSION_FLAG + val share = createShare(permission, isFolder = false) + assertFalse(SharePermissionManager.isCustomPermission(share)) + } + + @Test + fun testIsCustomPermissionShouldReturnTrueWhenCreatePermissionsGivenOnFolder() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.CREATE_PERMISSION_FLAG + val share = createShare(permission, isFolder = true) + assertTrue(SharePermissionManager.isCustomPermission(share)) + } + + @Test + fun testIsCustomPermissionShouldReturnTrueWhenMixedPermissionsOnFile() { + val permission = OCShare.READ_PERMISSION_FLAG + OCShare.UPDATE_PERMISSION_FLAG + val share = createShare(permission, isFolder = false) + assertTrue(SharePermissionManager.isCustomPermission(share)) + } + // endregion + + // region Attributes Tests + @Test + fun testToggleAllowDownloadAndSyncShouldCreateAttributeJsonIfNoneExists() { + val ocShare = OCShare().apply { + isFolder = true + shareType = ShareType.USER + permissions = 17 + } + ocShare.attributes = toggleAllowDownloadAndSync( + ocShare.attributes, + isChecked = true, + useV2DownloadAttributes = false + ) + assertTrue(ocShare.isAllowDownloadAndSyncEnabled(false)) + } + + @Test + fun testIsAllowDownloadAndSyncEnabledShouldReturnFalseIfAttributeIsMissing() { + val share = createShare(OCShare.READ_PERMISSION_FLAG, attributesJson = null) + assertFalse(share.isAllowDownloadAndSyncEnabled(false)) + } + // endregion +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/UploadDateTests.kt b/app/src/androidTest/java/com/nextcloud/utils/UploadDateTests.kt new file mode 100644 index 000000000000..be7cf735a580 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/UploadDateTests.kt @@ -0,0 +1,228 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Philipp Hasper + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.content.Context +import android.text.format.DateUtils +import androidx.test.platform.app.InstrumentationRegistry +import com.nextcloud.client.database.entity.UploadEntity +import com.nextcloud.client.database.entity.toOCUpload +import com.nextcloud.client.database.entity.toUploadEntity +import com.nextcloud.utils.date.DateFormatPattern +import com.owncloud.android.R +import com.owncloud.android.utils.DisplayUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class UploadDateTests { + + companion object { + private const val THIRTY_SECONDS = 30_000L + private const val ONE_MINUTE = 60_000L + private const val ONE_HOUR = 60 * ONE_MINUTE + private const val ONE_DAY = 24 * ONE_HOUR + + private const val ONE_YEAR = 365L * ONE_DAY + private const val ONE_MONTH = 30L * ONE_DAY + private const val ONE_WEEK = 7L * ONE_DAY + private const val TWO_HOURS = 2L * ONE_HOUR + } + + private lateinit var context: Context + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + @Test + fun uploadEntityConvertsToOCUploadAndBackCorrectly() { + val entity = UploadEntity( + id = 123, + localPath = "/local/file.txt", + remotePath = "/remote/file.txt", + accountName = "test@example.com", + fileSize = 1024L, + status = 2, + localBehaviour = 1, + uploadTime = null, + nameCollisionPolicy = 0, + isCreateRemoteFolder = 1, + uploadEndTimestamp = 0, + uploadEndTimestampLong = 1_650_000_000_000, + lastResult = 0, + isWhileChargingOnly = 1, + isWifiOnly = 1, + createdBy = 5, + folderUnlockToken = "token123" + ) + + val upload = entity.toOCUpload() + assertNotNull(upload) + assertEquals(entity.localPath, upload?.localPath) + assertEquals(entity.remotePath, upload?.remotePath) + assertEquals(entity.uploadEndTimestampLong, upload?.uploadEndTimestamp) + + val convertedEntity = upload!!.toUploadEntity() + assertEquals(entity.localPath, convertedEntity.localPath) + assertEquals(entity.remotePath, convertedEntity.remotePath) + assertEquals(entity.uploadEndTimestampLong, convertedEntity.uploadEndTimestampLong) + assertEquals(entity.isCreateRemoteFolder, convertedEntity.isCreateRemoteFolder) + assertEquals(entity.isWifiOnly, convertedEntity.isWifiOnly) + assertEquals(entity.isWhileChargingOnly, convertedEntity.isWhileChargingOnly) + } + + @Test + fun getRelativeDateTimeStringReturnsSecondsAgoForRecentPast() { + val result = DisplayUtils.getRelativeDateTimeString( + context, + System.currentTimeMillis() - THIRTY_SECONDS, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0 + ) + assertEquals(context.getString(R.string.file_list_seconds_ago), result.toString()) + } + + @Test + fun getRelativeDateTimeStringReturnsFutureAsAbsoluteWhenShowFutureIsFalse() { + val formatter = SimpleDateFormat("MMM d, yyyy h:mm:ss a", Locale.US) + val expected = formatter.format(Date(System.currentTimeMillis() + ONE_MINUTE)) + + val result = DisplayUtils.getRelativeDateTimeString( + context, + System.currentTimeMillis() + ONE_MINUTE, + DateUtils.SECOND_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0, + false + ) + assertEquals(expected, result.toString()) + } + + @Test + fun getRelativeDateTimeStringReturnsFutureAsRelativeWhenShowFutureIsTrue() { + val expected = "In 1 minute" + val time = System.currentTimeMillis() + ONE_MINUTE + + assertRelativeDateTimeString(time, expected, DateUtils.MINUTE_IN_MILLIS, showFuture = true) + } + + @Test + fun getRelativeDateTimeStringReturnsRelativeStringForHoursAgo() { + val expected = "2 hours ago" + val time = System.currentTimeMillis() - TWO_HOURS + + assertRelativeDateTimeString(time, expected, DateUtils.SECOND_IN_MILLIS) + } + + @Test + fun getRelativeDateTimeStringReturnsAbbreviatedStringForOneWeekAgo() { + val time = System.currentTimeMillis() - ONE_WEEK + val formatter = SimpleDateFormat(DateFormatPattern.MonthWithDate.pattern, Locale.US) + val expected = formatter.format(Date(time)) + + assertRelativeDateTimeString(time, expected) + } + + @Test + fun getRelativeDateTimeStringReturnsAbbreviatedStringForOneMonthAgo() { + val time = System.currentTimeMillis() - ONE_MONTH + val formatter = SimpleDateFormat(DateFormatPattern.MonthWithDate.pattern, Locale.US) + val expected = formatter.format(Date(time)) + + assertRelativeDateTimeString(time, expected, DateUtils.SECOND_IN_MILLIS) + } + + @Test + fun getRelativeDateTimeStringReturnsAbsoluteStringForOneYearAgo() { + val time = System.currentTimeMillis() - ONE_YEAR + val formatter = SimpleDateFormat("M/d/YYYY", Locale.US) + val expected = formatter.format(Date(time)) + + assertRelativeDateTimeString(time, expected, DateUtils.SECOND_IN_MILLIS) + } + + @Suppress("MagicNumber") + @Test + fun getRelativeDateTimeStringReturnsDaysForDayInMillis() { + var testTimestamp = System.currentTimeMillis() + var expected = "Today" + var result = DisplayUtils.getRelativeDateTimeString( + context, + testTimestamp, + DateUtils.DAY_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0, + false + ) + assertEquals(expected, result) + + testTimestamp = System.currentTimeMillis() - DateUtils.DAY_IN_MILLIS + expected = "Yesterday" + result = DisplayUtils.getRelativeDateTimeString( + context, + testTimestamp, + DateUtils.DAY_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0, + false + ) + assertEquals(expected, result) + + testTimestamp = System.currentTimeMillis() - 2 * DateUtils.DAY_IN_MILLIS + expected = "2 days ago" + result = DisplayUtils.getRelativeDateTimeString( + context, + testTimestamp, + DateUtils.DAY_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0, + false + ) + assertEquals(expected, result) + + testTimestamp = System.currentTimeMillis() - 7 * DateUtils.DAY_IN_MILLIS + expected = SimpleDateFormat(DateFormatPattern.MonthWithDate.pattern, Locale.US).format(testTimestamp) + result = DisplayUtils.getRelativeDateTimeString( + context, + testTimestamp, + DateUtils.DAY_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0, + false + ) + assertEquals(expected, result) + } + + private fun assertRelativeDateTimeString( + time: Long, + expected: String, + minResolution: Long = DateUtils.MINUTE_IN_MILLIS, + transitionResolution: Long = DateUtils.WEEK_IN_MILLIS, + showFuture: Boolean = false + ) { + val result = DisplayUtils.getRelativeDateTimeString( + context, + time, + minResolution, + transitionResolution, + 0, + showFuture + ) + assertEquals(expected.normalizeResult(), result.toString().normalizeResult()) + } + + private fun String.normalizeResult(): String = replace('\u202F', ' ').replace('\u00A0', ' ') +} diff --git a/app/src/androidTest/java/com/nextcloud/utils/WebDavParentPathTests.kt b/app/src/androidTest/java/com/nextcloud/utils/WebDavParentPathTests.kt new file mode 100644 index 000000000000..f08f2322fbe0 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/utils/WebDavParentPathTests.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.nextcloud.utils.extensions.webDavParentPath +import org.junit.Assert.assertEquals +import org.junit.Test + +@Suppress("TooManyFunctions") +class WebDavParentPathTests { + + @Test + fun testWebDavParentPathWhenGivenCorrectParentShouldReturnOneLevelAbove() { + assertEquals("/Photos/Vacation/", "/Photos/Vacation/beach.jpg".webDavParentPath()) + assertEquals("/work/docs/", "/work/docs/notes.txt".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenDeepNestingShouldReturnDirectParent() { + assertEquals("/a/b/c/d/", "/a/b/c/d/e.txt".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenRootFileShouldReturnRoot() { + assertEquals("/", "/image.png".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenSlashShouldReturnRoot() { + assertEquals("/", "/".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenEmptyStringShouldReturnRoot() { + assertEquals("/", "".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenOnlySlashesShouldReturnRoot() { + assertEquals("/", "///".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenRelativePathShouldReturnOneLevelAbove() { + assertEquals("Documents/", "Documents/file.pdf".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenSingleWordPathShouldReturnRoot() { + assertEquals("/", "readme.md".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenTrailingSlashShouldReturnOneLevelAbove() { + assertEquals("/Photos/", "/Photos/Vacation/".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenMultipleTrailingSlashesShouldReturnOneLevelAbove() { + assertEquals("/Photos/", "/Photos/Vacation///".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenEncodedSpacesShouldPreserveEncoding() { + assertEquals("/My%20Photos/", "/My%20Photos/beach%20photo.jpg".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenEncodedSpecialCharsShouldPreserveEncoding() { + assertEquals("/files/%23reports/", "/files/%23reports/q1%262.pdf".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenUnicodeCharsShouldReturnOneLevelAbove() { + assertEquals("/照片/假期/", "/照片/假期/海滩.jpg".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenSingleCharFileAtRootShouldReturnRoot() { + assertEquals("/", "/a".webDavParentPath()) + } + + @Test + fun testWebDavParentPathWhenGivenSingleCharDirShouldReturnOneLevelAbove() { + assertEquals("/a/", "/a/b".webDavParentPath()) + } +} diff --git a/app/src/androidTest/java/com/nmc/android/ui/LauncherActivityIT.kt b/app/src/androidTest/java/com/nmc/android/ui/LauncherActivityIT.kt new file mode 100644 index 000000000000..4b9012355c24 --- /dev/null +++ b/app/src/androidTest/java/com/nmc/android/ui/LauncherActivityIT.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nmc.android.ui + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LauncherActivityIT : AbstractIT() { + + @Test + fun testSplashScreenWithEmptyTitlesShouldHideTitles() { + launchActivity().use { scenario -> + onView(withId(R.id.ivSplash)).check(matches(isCompletelyDisplayed())) + onView( + withId(R.id.splashScreenBold) + ).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))) + onView( + withId(R.id.splashScreenNormal) + ).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))) + } + } + + @Test + fun testSplashScreenWithTitlesShouldShowTitles() { + launchActivity().use { scenario -> + onView(withId(R.id.ivSplash)).check(matches(isCompletelyDisplayed())) + + scenario.onActivity { + it.setSplashTitles("Example", "Cloud") + } + + val onePercentArea = ViewMatchers.isDisplayingAtLeast(1) + onView(withId(R.id.splashScreenBold)).check(matches(onePercentArea)) + onView(withId(R.id.splashScreenNormal)).check(matches(onePercentArea)) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java new file mode 100644 index 000000000000..ac58ccc05fc9 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java @@ -0,0 +1,550 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android; + +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; + +import com.facebook.testing.screenshot.Screenshot; +import com.facebook.testing.screenshot.internal.TestNameDetector; +import com.nextcloud.android.common.ui.theme.MaterialSchemes; +import com.nextcloud.android.common.ui.theme.MaterialSchemesImpl; +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.account.UserAccountManagerImpl; +import com.nextcloud.client.device.BatteryStatus; +import com.nextcloud.client.device.PowerManagementService; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.nextcloud.client.network.Connectivity; +import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.nextcloud.client.preferences.DarkMode; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.test.GrantStoragePermissionRule; +import com.nextcloud.test.RandomStringGenerator; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.db.OCUpload; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientFactory; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation; +import com.owncloud.android.lib.resources.status.CapabilityBooleanType; +import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.lib.resources.status.OwnCloudVersion; +import com.owncloud.android.operations.CreateFolderOperation; +import com.owncloud.android.operations.UploadFileOperation; +import com.owncloud.android.utils.FileStorageUtils; + +import org.apache.commons.io.FileUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.rules.TestRule; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.test.espresso.contrib.DrawerActions; +import androidx.test.espresso.intent.rule.IntentsTestRule; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.GrantPermissionRule; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; +import androidx.test.runner.lifecycle.Stage; + +import static androidx.test.InstrumentationRegistry.getInstrumentation; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * Common base for all integration tests. + */ +public abstract class AbstractIT { + @Rule + public final TestRule storagePermissionRule = GrantStoragePermissionRule.grant(); + + @Rule + public GrantPermissionRule notificationsPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS); + + protected static OwnCloudClient client; + protected static NextcloudClient nextcloudClient; + protected static Account account; + protected static User user; + protected static Context targetContext; + protected static String DARK_MODE = ""; + protected static String COLOR = ""; + + protected Activity currentActivity; + + protected FileDataStorageManager fileDataStorageManager = + new FileDataStorageManager(user, targetContext.getContentResolver()); + + protected ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(targetContext); + + @BeforeClass + public static void beforeAll() { + try { + // clean up + targetContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + AccountManager platformAccountManager = AccountManager.get(targetContext); + + for (Account account : platformAccountManager.getAccounts()) { + if (account.type.equalsIgnoreCase(MainApp.getAccountType(targetContext))) { + platformAccountManager.removeAccountExplicitly(account); + } + } + + account = createAccount("test@https://nextcloud.localhost"); + user = getUser(account); + + client = OwnCloudClientFactory.createOwnCloudClient(account, targetContext); + nextcloudClient = OwnCloudClientFactory.createNextcloudClient(user, targetContext); + } catch (OperationCanceledException | + IOException | + AccountUtils.AccountNotFoundException | + AuthenticatorException e) { + throw new RuntimeException("Error setting up clients", e); + } + + Bundle arguments = androidx.test.platform.app.InstrumentationRegistry.getArguments(); + + // color + String colorParameter = arguments.getString("COLOR"); + if (!TextUtils.isEmpty(colorParameter)) { + FileDataStorageManager fileDataStorageManager = new FileDataStorageManager(user, + targetContext.getContentResolver()); + + String colorHex = null; + COLOR = colorParameter; + switch (colorParameter) { + case "red": + colorHex = "#7c0000"; + break; + + case "green": + colorHex = "#00ff00"; + break; + + case "white": + colorHex = "#ffffff"; + break; + + case "black": + colorHex = "#000000"; + break; + + case "lightgreen": + colorHex = "#aaff00"; + break; + + default: + break; + } + + OCCapability capability = fileDataStorageManager.getCapability(account.name); + capability.setGroupfolders(CapabilityBooleanType.TRUE); + + if (colorHex != null) { + capability.setServerColor(colorHex); + } + + fileDataStorageManager.saveCapabilities(capability); + } + + // dark / light + String darkModeParameter = arguments.getString("DARKMODE"); + + if (darkModeParameter != null) { + if ("dark".equalsIgnoreCase(darkModeParameter)) { + DARK_MODE = "dark"; + AppPreferencesImpl.fromContext(targetContext).setDarkThemeMode(DarkMode.DARK); + MainApp.setAppTheme(DarkMode.DARK); + } else { + DARK_MODE = "light"; + } + } + + if ("light".equalsIgnoreCase(DARK_MODE) && "blue".equalsIgnoreCase(COLOR)) { + // use already existing names + DARK_MODE = ""; + COLOR = ""; + } + } + + protected void testOnlyOnServer(OwnCloudVersion version) throws AccountUtils.AccountNotFoundException { + OCCapability ocCapability = getCapability(); + assumeTrue(ocCapability.getVersion().isNewerOrEqual(version)); + } + + protected OCCapability getCapability() throws AccountUtils.AccountNotFoundException { + NextcloudClient client = OwnCloudClientFactory.createNextcloudClient(user, targetContext); + + return new GetCapabilitiesRemoteOperation().execute(client).getResultData(); + } + + @Before + public void enableAccessibilityChecks() { + androidx.test.espresso.accessibility.AccessibilityChecks.enable().setRunChecksFromRootView(true); + } + + @After + public void after() { + fileDataStorageManager.removeLocalFiles(user, fileDataStorageManager); + fileDataStorageManager.deleteAllFiles(); + } + + protected FileDataStorageManager getStorageManager() { + return fileDataStorageManager; + } + + protected Account[] getAllAccounts() { + return AccountManager.get(targetContext).getAccounts(); + } + + protected static List createDummyFiles() throws IOException { + File tempPath = new File(FileStorageUtils.getTemporalPath(account.name)); + if (!tempPath.exists()) { + assertTrue(tempPath.mkdirs()); + } + + assertTrue(tempPath.exists()); + + return Arrays.asList( + createFile("empty.txt", 0), + createFile("nonEmpty.txt", 100), + createFile("chunkedFile.txt", 500000) + ); + } + + protected static File getDummyFile(String name) throws IOException { + File file = new File(FileStorageUtils.getInternalTemporalPath(account.name, targetContext) + File.separator + name); + + if (file.exists()) { + return file; + } else if (name.endsWith("/")) { + file.mkdirs(); + return file; + } else { + return switch (name) { + case "empty.txt" -> createFile("empty.txt", 0); + case "nonEmpty.txt" -> createFile("nonEmpty.txt", 100); + case "chunkedFile.txt" -> createFile("chunkedFile.txt", 500000); + default -> createFile(name, 0); + }; + } + } + + public static File createFile(String name, int iteration) throws IOException { + File file = new File(FileStorageUtils.getTemporalPath(account.name) + File.separator + name); + if (!file.getParentFile().exists()) { + assertTrue(file.getParentFile().mkdirs()); + } + + file.createNewFile(); + + FileWriter writer = new FileWriter(file); + + for (int i = 0; i < iteration; i++) { + writer.write("123123123123123123123123123\n"); + } + writer.flush(); + writer.close(); + + return file; + } + + protected File getFile(String filename) throws IOException { + InputStream inputStream = getInstrumentation().getContext().getAssets().open(filename); + File temp = File.createTempFile("file", "file"); + FileUtils.copyInputStreamToFile(inputStream, temp); + + return temp; + } + + protected void waitForIdleSync() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + public void onIdleSync(Runnable recipient) { + InstrumentationRegistry.getInstrumentation().waitForIdle(recipient); + } + + protected void openDrawer(IntentsTestRule activityRule) { + Activity sut = activityRule.launchActivity(null); + + shortSleep(); + + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); + + waitForIdleSync(); + + screenshot(sut); + } + + protected Activity getCurrentActivity() { + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + Collection resumedActivities = ActivityLifecycleMonitorRegistry + .getInstance() + .getActivitiesInStage(Stage.RESUMED); + + if (resumedActivities.iterator().hasNext()) { + currentActivity = resumedActivities.iterator().next(); + } + }); + + return currentActivity; + } + + protected static void shortSleep() { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + protected void longSleep() { + try { + Thread.sleep(20000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + protected void sleep(int second) { + try { + Thread.sleep(1000L * second); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + public void createFolder(String remotePath) { + RemoteOperationResult check = new ExistenceCheckRemoteOperation(remotePath, false).execute(client); + + if (!check.isSuccess()) { + assertTrue(new CreateFolderOperation(remotePath, user, targetContext, getStorageManager()) + .execute(client) + .isSuccess()); + } + } + + public void uploadFile(File file, String remotePath) { + OCUpload ocUpload = new OCUpload(file.getAbsolutePath(), remotePath, account.name); + + uploadOCUpload(ocUpload); + } + + public void uploadOCUpload(OCUpload ocUpload) { + ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { + + } + + @Override + public boolean isConnected() { + return false; + } + + @Override + public boolean isInternetWalled() { + return false; + } + + @Override + public Connectivity getConnectivity() { + return Connectivity.CONNECTED_WIFI; + } + }; + + PowerManagementService powerManagementServiceMock = new PowerManagementService() { + @NonNull + @Override + public BatteryStatus getBattery() { + return new BatteryStatus(); + } + + @Override + public boolean isPowerSavingEnabled() { + return false; + } + }; + + UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext); + UploadsStorageManager uploadsStorageManager = new UploadsStorageManager(accountManager, + targetContext.getContentResolver()); + + UploadFileOperation newUpload = new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + getStorageManager() + ); + newUpload.addRenameUploadListener(() -> { + // dummy + }); + + newUpload.setRemoteFolderToBeCreated(); + + var result = newUpload.execute(client); + assertTrue(result.getLogMessage(), result.isSuccess()); + } + + protected void enableRTL() { + Locale locale = new Locale("ar"); + Resources resources = InstrumentationRegistry.getInstrumentation().getTargetContext().getResources(); + Configuration config = resources.getConfiguration(); + config.setLocale(locale); + resources.updateConfiguration(config, null); + } + + protected void resetLocale() { + Locale locale = new Locale("en"); + Resources resources = InstrumentationRegistry.getInstrumentation().getTargetContext().getResources(); + Configuration config = resources.getConfiguration(); + config.setLocale(locale); + resources.updateConfiguration(config, null); + } + + protected void screenshot(View view) { + screenshot(view, ""); + } + + public void screenshotViaName(Activity activity, String name) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Screenshot.snapActivity(activity).setName(name).record(); + } + } + + protected void screenshotViaName(View view, String name) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Screenshot.snap(view).setName(name).record(); + } + } + + protected void screenshot(View view, String prefix) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Screenshot.snap(view).setName(createName(prefix)).record(); + } + } + + protected void screenshot(Activity sut) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Screenshot.snapActivity(sut).setName(createName()).record(); + } + } + + protected void screenshot(DialogFragment dialogFragment, String prefix) { + screenshot(Objects.requireNonNull(dialogFragment.requireDialog().getWindow()).getDecorView(), prefix); + } + + private String createName() { + return createName(""); + } + + public String createName(String name, String prefix) { + if (!TextUtils.isEmpty(prefix)) { + name = name + "_" + prefix; + } + + if (!DARK_MODE.isEmpty()) { + name = name + "_" + DARK_MODE; + } + + if (!COLOR.isEmpty()) { + name = name + "_" + COLOR; + } + + return name; + } + + private String createName(String prefix) { + String name = TestNameDetector.getTestClass() + "_" + TestNameDetector.getTestName(); + return createName(name, prefix); + } + + public static String getUserId(User user) { + return AccountManager.get(targetContext).getUserData(user.toPlatformAccount(), KEY_USER_ID); + } + + public String getRandomName() { + return getRandomName(5); + } + + public String getRandomName(int length) { + return RandomStringGenerator.make(length); + } + + protected static User getUser(Account account) { + Optional optionalUser = UserAccountManagerImpl.fromContext(targetContext).getUser(account.name); + return optionalUser.orElseThrow(IllegalAccessError::new); + } + + protected static Account createAccount(String name) { + AccountManager platformAccountManager = AccountManager.get(targetContext); + + Account temp = new Account(name, MainApp.getAccountType(targetContext)); + int atPos = name.lastIndexOf('@'); + platformAccountManager.addAccountExplicitly(temp, "password", null); + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_BASE_URL, + name.substring(atPos + 1)); + platformAccountManager.setUserData(temp, KEY_USER_ID, name.substring(0, atPos)); + + Account account = UserAccountManagerImpl.fromContext(targetContext).getAccountByName(name); + if (Objects.equals(account.type, targetContext.getString(R.string.anonymous_account_type))) { + throw new RuntimeException("Could not get account with name " + name); + } + return account; + } + + protected static boolean removeAccount(Account account) { + return AccountManager.get(targetContext).removeAccountExplicitly(account); + } + + protected MaterialSchemes getMaterialSchemesForCurrentUser() { + return new MaterialSchemesImpl(R.color.primary, false); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java new file mode 100644 index 000000000000..1b0e1b8d3c17 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java @@ -0,0 +1,293 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.net.Uri; +import android.os.Bundle; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.account.UserAccountManagerImpl; +import com.nextcloud.client.device.BatteryStatus; +import com.nextcloud.client.device.PowerManagementService; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.nextcloud.client.network.Connectivity; +import com.nextcloud.client.network.ConnectivityService; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.db.OCUpload; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientFactory; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation; +import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation; +import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.operations.RefreshFolderOperation; +import com.owncloud.android.operations.UploadFileOperation; +import com.owncloud.android.utils.MimeType; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.httpclient.methods.GetMethod; +import org.junit.After; +import org.junit.Assert; +import org.junit.BeforeClass; + +import java.io.File; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +import androidx.annotation.NonNull; +import androidx.test.platform.app.InstrumentationRegistry; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/// Common base for all integration tests requiring a server connection. +/// ATTENTION: Deletes ALL files of the test user on the server after each test run. +/// So you MUST use a dedicated test user. +/// Uses server, user and password given as `testInstrumentationRunnerArgument` +/// - TEST_SERVER_URL +/// - TEST_SERVER_USERNAME +/// - TEST_SERVER_PASSWORD +/// These are supplied via build.gradle, which takes them from gradle.properties. +/// So look in the latter file to set to your own server & test user. +public abstract class AbstractOnServerIT extends AbstractIT { + @BeforeClass + public static void beforeAll() { + try { + // clean up + targetContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + AccountManager platformAccountManager = AccountManager.get(targetContext); + + for (Account account : platformAccountManager.getAccounts()) { + if (account.type.equalsIgnoreCase("nextcloud")) { + platformAccountManager.removeAccountExplicitly(account); + } + } + + Bundle arguments = androidx.test.platform.app.InstrumentationRegistry.getArguments(); + + Uri baseUrl = Uri.parse(arguments.getString("TEST_SERVER_URL")); + String loginName = arguments.getString("TEST_SERVER_USERNAME"); + String password = arguments.getString("TEST_SERVER_PASSWORD"); + + Account temp = new Account(loginName + "@" + baseUrl, MainApp.getAccountType(targetContext)); + UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext); + if (!accountManager.exists(temp)) { + platformAccountManager.addAccountExplicitly(temp, password, null); + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_ACCOUNT_VERSION, + Integer.toString(UserAccountManager.ACCOUNT_VERSION)); + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_VERSION, "14.0.0.0"); + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_BASE_URL, baseUrl.toString()); + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_USER_ID, loginName); // same as userId + } + + final UserAccountManager userAccountManager = UserAccountManagerImpl.fromContext(targetContext); + account = userAccountManager.getAccountByName(loginName + "@" + baseUrl); + + if (Objects.equals(account.type, targetContext.getString(R.string.anonymous_account_type))) { + throw new RuntimeException("Could not get account with name " + loginName + "@" + baseUrl); + } + + Optional optionalUser = userAccountManager.getUser(account.name); + user = optionalUser.orElseThrow(IllegalAccessError::new); + + client = OwnCloudClientFactory.createOwnCloudClient(account, targetContext); + nextcloudClient = OwnCloudClientFactory.createNextcloudClient(user, targetContext); + + createDummyFiles(); + + waitForServer(client, baseUrl); + + // deleteAllFilesOnServer(); // makes sure that no file/folder is in root + + } catch (OperationCanceledException | + IOException | + AccountUtils.AccountNotFoundException | + AuthenticatorException e) { + throw new RuntimeException("Error setting up clients", e); + } + } + + @After + public void after() { + deleteAllFilesOnServer(); + + super.after(); + } + + private static boolean isFolder(RemoteFile file) { + // TODO: should probably move to RemoteFile class + return MimeType.DIRECTORY.equals(file.getMimeType()) || MimeType.WEBDAV_FOLDER.equals(file.getMimeType()); + } + + public static void deleteAllFilesOnServer() { + var result = new ReadFolderRemoteOperation("/").execute(client); + assertTrue(result.getLogMessage(targetContext), result.isSuccess()); + + for (Object object : result.getData()) { + RemoteFile remoteFile = (RemoteFile) object; + + if (!Objects.equals(remoteFile.getRemotePath(), "/")) { + if (remoteFile.isEncrypted()) { + ToggleEncryptionRemoteOperation operation = new ToggleEncryptionRemoteOperation(remoteFile.getLocalId(), + remoteFile.getRemotePath(), + false); + + boolean operationResult = operation + .execute(client) + .isSuccess(); + + if (!operationResult && isFolder(remoteFile)) { + // Deleting encrypted folder is not possible due to bug + // https://github.com/nextcloud/end_to_end_encryption/issues/421 + // Toggling encryption also fails, when the folder is not empty. So we ignore this folder + continue; + } + + assertTrue(operationResult); + } + + boolean removeResult = false; + for (int i = 0; i < 5; i++) { + removeResult = new RemoveFileRemoteOperation(remoteFile.getRemotePath()) + .execute(client) + .isSuccess(); + + if (removeResult) { + break; + } + + shortSleep(); + } + + assertTrue(removeResult); + } + } + } + + private static void waitForServer(OwnCloudClient client, Uri baseUrl) { + GetMethod get = new GetMethod(baseUrl + "/status.php"); + + try { + int i = 0; + while (client.executeMethod(get) != HttpStatus.SC_OK && i < 3) { + System.out.println("wait…"); + Thread.sleep(60 * 1000); + i++; + } + + if (i == 3) { + Assert.fail("Server not ready!"); + } + + } catch (IOException | InterruptedException e) { + e.printStackTrace(); + } + } + + public void uploadOCUpload(OCUpload ocUpload) { + uploadOCUpload(ocUpload, FileUploadWorker.LOCAL_BEHAVIOUR_COPY); + } + + public void uploadOCUpload(OCUpload ocUpload, int localBehaviour) { + ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { + + } + + @Override + public boolean isConnected() { + return false; + } + + @Override + public boolean isInternetWalled() { + return false; + } + + @Override + public Connectivity getConnectivity() { + return Connectivity.CONNECTED_WIFI; + } + }; + + PowerManagementService powerManagementServiceMock = new PowerManagementService() { + @NonNull + @Override + public BatteryStatus getBattery() { + return new BatteryStatus(); + } + + @Override + public boolean isPowerSavingEnabled() { + return false; + } + }; + + UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext); + UploadsStorageManager uploadsStorageManager = new UploadsStorageManager(accountManager, + targetContext.getContentResolver()); + + UploadFileOperation newUpload = new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + localBehaviour, + targetContext, + false, + false, + getStorageManager() + ); + newUpload.addRenameUploadListener(() -> { + // dummy + }); + + newUpload.setRemoteFolderToBeCreated(); + + var result = newUpload.execute(client); + assertTrue(result.getLogMessage(), result.isSuccess()); + + OCFile parentFolder = getStorageManager() + .getFileByEncryptedRemotePath(new File(ocUpload.getRemotePath()).getParent() + "/"); + String uploadedFileName = new File(ocUpload.getRemotePath()).getName(); + OCFile uploadedFile = getStorageManager(). + getFileByDecryptedRemotePath(parentFolder.getDecryptedRemotePath() + uploadedFileName); + + assertNotNull(uploadedFile); + assertNotNull(uploadedFile.getRemoteId()); + assertNotNull(uploadedFile.getPermissions()); + + if (localBehaviour == FileUploadWorker.LOCAL_BEHAVIOUR_COPY || + localBehaviour == FileUploadWorker.LOCAL_BEHAVIOUR_MOVE) { + assertTrue(new File(uploadedFile.getStoragePath()).exists()); + } + } + + protected void refreshFolder(String path) { + assertTrue(new RefreshFolderOperation(getStorageManager().getFileByEncryptedRemotePath(path), + System.currentTimeMillis(), + false, + false, + getStorageManager(), + user, + targetContext + ).execute(client).isSuccess()); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/DownloadIT.java b/app/src/androidTest/java/com/owncloud/android/DownloadIT.java new file mode 100644 index 000000000000..f8fd99517f1c --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/DownloadIT.java @@ -0,0 +1,108 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android; + +import android.net.Uri; + +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.db.OCUpload; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.operations.DownloadFileOperation; +import com.owncloud.android.operations.RefreshFolderOperation; +import com.owncloud.android.operations.RemoveFileOperation; +import com.owncloud.android.utils.FileStorageUtils; + +import org.junit.After; +import org.junit.Test; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +/** + * Tests related to file downloads. + */ +public class DownloadIT extends AbstractOnServerIT { + private static final String FOLDER = "/testUpload/"; + + @After + public void after() { + RemoteOperationResult result = new RefreshFolderOperation(getStorageManager().getFileByPath("/"), + System.currentTimeMillis() / 1000L, + false, + true, + getStorageManager(), + user, + targetContext) + .execute(client); + + // cleanup only if folder exists + if (result.isSuccess() && getStorageManager().getFileByDecryptedRemotePath(FOLDER) != null) { + new RemoveFileOperation(getStorageManager().getFileByDecryptedRemotePath(FOLDER), + false, + user, + false, + targetContext, + getStorageManager()) + .execute(client); + } + } + + @Test + public void verifyDownload() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt", + FOLDER + "nonEmpty.txt", + account.name); + + uploadOCUpload(ocUpload); + + OCUpload ocUpload2 = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt", + FOLDER + "nonEmpty2.txt", + account.name); + + uploadOCUpload(ocUpload2); + + refreshFolder("/"); + refreshFolder(FOLDER); + + OCFile file1 = fileDataStorageManager.getFileByDecryptedRemotePath(FOLDER + "nonEmpty.txt"); + OCFile file2 = fileDataStorageManager.getFileByDecryptedRemotePath(FOLDER + "nonEmpty2.txt"); + verifyDownload(file1, file2); + + assertTrue(new DownloadFileOperation(user, file1, targetContext).execute(client).isSuccess()); + assertTrue(new DownloadFileOperation(user, file2, targetContext).execute(client).isSuccess()); + + refreshFolder(FOLDER); + + file1 = fileDataStorageManager.getFileByDecryptedRemotePath(FOLDER + "nonEmpty.txt"); + file2 = fileDataStorageManager.getFileByDecryptedRemotePath(FOLDER + "nonEmpty2.txt"); + + verifyDownload(file1, file2); + } + + private void verifyDownload(OCFile file1, OCFile file2) { + assertNotNull(file1); + assertNotNull(file2); + assertNotSame(file1.getStoragePath(), file2.getStoragePath()); + + assertTrue(new File(file1.getStoragePath()).exists()); + assertTrue(new File(file2.getStoragePath()).exists()); + + // test against hardcoded path to make sure that it is correct + assertEquals("/storage/emulated/0/Android/media/"+targetContext.getPackageName()+"/nextcloud/" + + Uri.encode(account.name, "@") + "/testUpload/nonEmpty.txt", + file1.getStoragePath()); + assertEquals("/storage/emulated/0/Android/media/"+targetContext.getPackageName()+"/nextcloud/" + + Uri.encode(account.name, "@") + "/testUpload/nonEmpty2.txt", + file2.getStoragePath()); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptionIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptionIT.kt new file mode 100644 index 000000000000..ec53f46b1b65 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/EncryptionIT.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android + +import com.owncloud.android.datamodel.OCFile +import java.security.SecureRandom + +open class EncryptionIT : AbstractIT() { + + fun testFolder(): OCFile { + val rootPath = "/" + val folderPath = "/TestFolder/" + + OCFile(rootPath).apply { + storageManager.saveFile(this) + } + + return OCFile(folderPath).apply { + decryptedRemotePath = folderPath + isEncrypted = true + fileLength = SecureRandom().nextLong() + setFolder() + parentId = storageManager.getFileByDecryptedRemotePath(rootPath)!!.fileId + storageManager.saveFile(this) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/FileIT.java b/app/src/androidTest/java/com/owncloud/android/FileIT.java new file mode 100644 index 000000000000..2ba362250cdf --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/FileIT.java @@ -0,0 +1,150 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android; + +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.operations.CreateFolderOperation; +import com.owncloud.android.operations.RemoveFileOperation; +import com.owncloud.android.operations.RenameFileOperation; +import com.owncloud.android.operations.SynchronizeFolderOperation; +import com.owncloud.android.operations.common.SyncOperation; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.IOException; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; + +/** + * Tests related to file operations. + */ +@RunWith(AndroidJUnit4.class) +public class FileIT extends AbstractOnServerIT { + + @Test + public void testCreateFolder() { + String path = "/testFolder/"; + + // folder does not exist yet + assertNull(getStorageManager().getFileByPath(path)); + + SyncOperation syncOp = new CreateFolderOperation(path, user, targetContext, getStorageManager()); + RemoteOperationResult result = syncOp.execute(client); + + assertTrue(result.toString(), result.isSuccess()); + + // folder exists + OCFile file = getStorageManager().getFileByPath(path); + assertTrue(file.isFolder()); + + // cleanup + assertTrue(new RemoveFileOperation(file, false, user, false, targetContext, getStorageManager()) + .execute(client) + .isSuccess()); + } + + @Test + public void testCreateNonExistingSubFolder() { + String path = "/subFolder/1/2/3/4/5/"; + // folder does not exist yet + assertNull(getStorageManager().getFileByPath(path)); + + SyncOperation syncOp = new CreateFolderOperation(path, user, targetContext, getStorageManager()); + RemoteOperationResult result = syncOp.execute(client); + assertTrue(result.toString(), result.isSuccess()); + + // folder exists + OCFile file = getStorageManager().getFileByPath(path); + assertTrue(file.isFolder()); + + // cleanup + new RemoveFileOperation(file, + false, + user, + false, + targetContext, + getStorageManager()) + .execute(client); + } + + @Test + public void testRemoteIdNull() { + getStorageManager().deleteAllFiles(); + assertEquals(0, getStorageManager().getAllFiles().size()); + + OCFile test = new OCFile("/123.txt"); + getStorageManager().saveFile(test); + assertEquals(1, getStorageManager().getAllFiles().size()); + + getStorageManager().deleteAllFiles(); + assertEquals(0, getStorageManager().getAllFiles().size()); + } + + @Test + public void testRenameFolder() throws IOException { + String folderPath = "/testRenameFolder/"; + + // create folder + createFolder(folderPath); + + // upload file inside it + uploadFile(getDummyFile("nonEmpty.txt"), folderPath + "text.txt"); + + // sync folder + assertTrue(new SynchronizeFolderOperation(targetContext, + folderPath, + user, + fileDataStorageManager, + false) + .execute(targetContext) + .isSuccess()); + + // check if file exists + String storagePath1 = fileDataStorageManager.getFileByDecryptedRemotePath(folderPath).getStoragePath(); + assertTrue(new File(storagePath1).exists()); + + String storagePath2 = fileDataStorageManager + .getFileByDecryptedRemotePath(folderPath + "text.txt") + .getStoragePath(); + assertTrue(new File(storagePath2).exists()); + + shortSleep(); + + // Rename + assertTrue( + new RenameFileOperation(folderPath, "test123", fileDataStorageManager) + .execute(targetContext) + .isSuccess() + ); + + // after rename check new location + assertTrue( + new File(fileDataStorageManager.getFileByDecryptedRemotePath("/test123/").getStoragePath()) + .exists() + ); + assertTrue( + new File(fileDataStorageManager.getFileByDecryptedRemotePath("/test123/text.txt").getStoragePath()) + .exists() + ); + + // old files do no exist + assertNull(fileDataStorageManager.getFileByDecryptedRemotePath(folderPath)); + assertNull(fileDataStorageManager.getFileByDecryptedRemotePath(folderPath + "text.txt")); + + // local files also do not exist + assertFalse(new File(storagePath1).exists()); + assertFalse(new File(storagePath2).exists()); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ScreenshotsIT.kt b/app/src/androidTest/java/com/owncloud/android/ScreenshotsIT.kt new file mode 100644 index 000000000000..100395a13fa9 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ScreenshotsIT.kt @@ -0,0 +1,137 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android + +import android.Manifest +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.DrawerActions +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.rule.GrantPermissionRule +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.activity.SettingsActivity +import com.owncloud.android.ui.activity.SyncedFoldersActivity +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test +import tools.fastlane.screengrab.Screengrab +import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy +import tools.fastlane.screengrab.locale.LocaleTestRule + +class ScreenshotsIT : AbstractIT() { + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.POST_NOTIFICATIONS + ) + + @Test + @ScreenshotTest + fun gridViewScreenshot() { + launchActivity().use { + onView(withId(R.id.switch_grid_view_button)).perform(click()) + + onView(isRoot()).check(matches(isDisplayed())) + Screengrab.screenshot("01_gridView") + + // Switch back + onView(withId(R.id.switch_grid_view_button)).perform(click()) + + assertTrue(true) + } + } + + @Test + @ScreenshotTest + fun listViewScreenshot() { + launchActivity().use { + val path = "/Camera/" + OCFile(path).apply { + storageManager.saveFile(this) + } + onView(withId(R.id.list_root)).perform(click()) + + onView(isRoot()).check(matches(isDisplayed())) + Screengrab.screenshot("02_listView") + assertTrue(true) + } + } + + @Test + @ScreenshotTest + fun drawerScreenshot() { + launchActivity().use { + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()) + + onView(isRoot()).check(matches(isDisplayed())) + Screengrab.screenshot("03_drawer") + + onView(withId(R.id.drawer_layout)).perform(DrawerActions.close()) + assertTrue(true) + } + } + + @Test + @ScreenshotTest + fun multipleAccountsScreenshot() { + launchActivity().use { + onView(withId(R.id.switch_account_button)).perform(click()) + + onView(isRoot()).check(matches(isDisplayed())) + Screengrab.screenshot("04_accounts") + + Espresso.pressBack() + assertTrue(true) + } + } + + @Test + @ScreenshotTest + fun autoUploadScreenshot() { + launchActivity().use { + onView(isRoot()).check(matches(isDisplayed())) + Screengrab.screenshot("05_autoUpload") + assertTrue(true) + } + } + + @Test + @ScreenshotTest + fun davdroidScreenshot() { + launchActivity().use { + onView(withText(R.string.prefs_category_more)).perform(scrollTo()) + + onView(isRoot()).check(matches(isDisplayed())) + Screengrab.screenshot("06_davdroid") + assertTrue(true) + } + } + + companion object { + @ClassRule + @JvmField + val localeTestRule: LocaleTestRule = LocaleTestRule() + + @BeforeClass + @JvmStatic + fun beforeScreenshot() { + Screengrab.setDefaultScreenshotStrategy(UiAutomatorScreenshotStrategy()) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/UploadIT.java b/app/src/androidTest/java/com/owncloud/android/UploadIT.java new file mode 100644 index 000000000000..8072bb5c1805 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/UploadIT.java @@ -0,0 +1,521 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android; + +import com.nextcloud.client.account.UserAccountManagerImpl; +import com.nextcloud.client.device.BatteryStatus; +import com.nextcloud.client.device.PowerManagementService; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.nextcloud.client.network.Connectivity; +import com.nextcloud.client.network.ConnectivityService; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.db.OCUpload; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.model.GeoLocation; +import com.owncloud.android.lib.resources.files.model.ImageDimension; +import com.owncloud.android.lib.resources.status.NextcloudVersion; +import com.owncloud.android.operations.RefreshFolderOperation; +import com.owncloud.android.operations.RemoveFileOperation; +import com.owncloud.android.operations.UploadFileOperation; +import com.owncloud.android.utils.FileStorageUtils; + +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import androidx.annotation.NonNull; + +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertFalse; +import static junit.framework.TestCase.assertNotNull; +import static junit.framework.TestCase.assertTrue; + +/** + * Tests related to file uploads. + */ +public class UploadIT extends AbstractOnServerIT { + private static final String FOLDER = "/testUpload/"; + + private UploadsStorageManager uploadsStorageManager = + new UploadsStorageManager(UserAccountManagerImpl.fromContext(targetContext), + targetContext.getContentResolver()); + + private ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { + + } + + @Override + public boolean isConnected() { + return false; + } + + @Override + public boolean isInternetWalled() { + return false; + } + + @Override + public Connectivity getConnectivity() { + return Connectivity.CONNECTED_WIFI; + } + }; + + private PowerManagementService powerManagementServiceMock = new PowerManagementService() { + @Override + public boolean isPowerSavingEnabled() { + return false; + } + @NonNull + @Override + public BatteryStatus getBattery() { + return new BatteryStatus(false, 0); + } + }; + + @Before + public void before() throws IOException { + // make sure that every file is available, even after tests that remove source file + createDummyFiles(); + } + + @Test + public void testEmptyUpload() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", + FOLDER + "empty.txt", + account.name); + + uploadOCUpload(ocUpload); + } + + @Test + public void testNonEmptyUpload() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt", + FOLDER + "nonEmpty.txt", + account.name); + + uploadOCUpload(ocUpload); + } + + @Test + public void testUploadWithCopy() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt", + FOLDER + "nonEmpty.txt", + account.name); + + uploadOCUpload(ocUpload, FileUploadWorker.LOCAL_BEHAVIOUR_COPY); + + File originalFile = new File(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt"); + OCFile uploadedFile = fileDataStorageManager.getFileByDecryptedRemotePath(FOLDER + "nonEmpty.txt"); + + assertTrue(originalFile.exists()); + assertTrue(new File(uploadedFile.getStoragePath()).exists()); + verifyStoragePath(uploadedFile); + } + + @Test + public void testUploadWithMove() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt", + FOLDER + "nonEmpty.txt", + account.name); + + uploadOCUpload(ocUpload, FileUploadWorker.LOCAL_BEHAVIOUR_MOVE); + + File originalFile = new File(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt"); + OCFile uploadedFile = fileDataStorageManager.getFileByDecryptedRemotePath(FOLDER + "nonEmpty.txt"); + + assertFalse(originalFile.exists()); + assertTrue(new File(uploadedFile.getStoragePath()).exists()); + verifyStoragePath(uploadedFile); + } + + @Test + public void testUploadWithForget() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt", + FOLDER + "nonEmpty.txt", + account.name); + + uploadOCUpload(ocUpload, FileUploadWorker.LOCAL_BEHAVIOUR_FORGET); + + File originalFile = new File(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt"); + OCFile uploadedFile = fileDataStorageManager.getFileByDecryptedRemotePath(FOLDER + "nonEmpty.txt"); + + assertTrue(originalFile.exists()); + assertFalse(new File(uploadedFile.getStoragePath()).exists()); + assertTrue(uploadedFile.getStoragePath().isEmpty()); + } + + @Test + public void testUploadWithDelete() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt", + FOLDER + "nonEmpty.txt", + account.name); + + uploadOCUpload(ocUpload, FileUploadWorker.LOCAL_BEHAVIOUR_DELETE); + + File originalFile = new File(FileStorageUtils.getTemporalPath(account.name) + "/nonEmpty.txt"); + OCFile uploadedFile = fileDataStorageManager.getFileByDecryptedRemotePath(FOLDER + "nonEmpty.txt"); + + assertFalse(originalFile.exists()); + assertFalse(new File(uploadedFile.getStoragePath()).exists()); + assertTrue(uploadedFile.getStoragePath().isEmpty()); + } + + @Test + public void testChunkedUpload() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/chunkedFile.txt", + FOLDER + "chunkedFile.txt", account.name); + + uploadOCUpload(ocUpload); + } + + @Test + public void testUploadInNonExistingFolder() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", + FOLDER + "2/3/4/1.txt", account.name); + + uploadOCUpload(ocUpload); + } + + @Test + public void testUploadOnChargingOnlyButNotCharging() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", + FOLDER + "notCharging.txt", account.name); + ocUpload.setWhileChargingOnly(true); + + UploadFileOperation newUpload = new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + true, + getStorageManager() + ); + newUpload.setRemoteFolderToBeCreated(); + newUpload.addRenameUploadListener(() -> { + // dummy + }); + + RemoteOperationResult result = newUpload.execute(client); + assertFalse(result.toString(), result.isSuccess()); + assertEquals(RemoteOperationResult.ResultCode.DELAYED_FOR_CHARGING, result.getCode()); + } + + @Test + public void testUploadOnChargingOnlyAndCharging() { + PowerManagementService powerManagementServiceMock = new PowerManagementService() { + @Override + public boolean isPowerSavingEnabled() { + return false; + } + + @NonNull + @Override + public BatteryStatus getBattery() { + return new BatteryStatus(true, 100); + } + }; + + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", + FOLDER + "charging.txt", account.name); + ocUpload.setWhileChargingOnly(true); + + UploadFileOperation newUpload = new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + true, + getStorageManager() + ); + newUpload.setRemoteFolderToBeCreated(); + newUpload.addRenameUploadListener(() -> { + // dummy + }); + + RemoteOperationResult result = newUpload.execute(client); + assertTrue(result.toString(), result.isSuccess()); + } + + @Test + public void testUploadOnWifiOnlyButNoWifi() { + ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { + + } + + @Override + public boolean isConnected() { + return false; + } + + @Override + public boolean isInternetWalled() { + return false; + } + + @Override + public Connectivity getConnectivity() { + return new Connectivity(true, false, false, true); + } + }; + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", + FOLDER + "noWifi.txt", account.name); + ocUpload.setUseWifiOnly(true); + + UploadFileOperation newUpload = new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + true, + false, + getStorageManager() + ); + newUpload.setRemoteFolderToBeCreated(); + newUpload.addRenameUploadListener(() -> { + // dummy + }); + + RemoteOperationResult result = newUpload.execute(client); + assertFalse(result.toString(), result.isSuccess()); + assertEquals(RemoteOperationResult.ResultCode.DELAYED_FOR_WIFI, result.getCode()); + } + + @Test + public void testUploadOnWifiOnlyAndWifi() { + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", + FOLDER + "wifi.txt", account.name); + ocUpload.setWhileChargingOnly(true); + + UploadFileOperation newUpload = new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + true, + false, + getStorageManager() + ); + newUpload.setRemoteFolderToBeCreated(); + newUpload.addRenameUploadListener(() -> { + // dummy + }); + + RemoteOperationResult result = newUpload.execute(client); + assertTrue(result.toString(), result.isSuccess()); + + // cleanup + new RemoveFileOperation(getStorageManager().getFileByDecryptedRemotePath(FOLDER), + false, + user, + false, + targetContext, + getStorageManager()) + .execute(client); + } + + @Test + public void testUploadOnWifiOnlyButMeteredWifi() { + ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { + + } + + @Override + public boolean isConnected() { + return false; + } + + @Override + public boolean isInternetWalled() { + return false; + } + + @Override + public Connectivity getConnectivity() { + return new Connectivity(true, true, true, true); + } + }; + OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", + FOLDER + "noWifi.txt", + account.name); + ocUpload.setUseWifiOnly(true); + + UploadFileOperation newUpload = new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + true, + false, + getStorageManager() + ); + newUpload.setRemoteFolderToBeCreated(); + newUpload.addRenameUploadListener(() -> { + // dummy + }); + + RemoteOperationResult result = newUpload.execute(client); + assertFalse(result.toString(), result.isSuccess()); + assertEquals(RemoteOperationResult.ResultCode.DELAYED_FOR_WIFI, result.getCode()); + } + + @Test + public void testCreationAndUploadTimestamp() throws IOException, AccountUtils.AccountNotFoundException { + testOnlyOnServer(NextcloudVersion.nextcloud_27); + + File file = getDummyFile("empty.txt"); + String remotePath = "/testFile.txt"; + OCUpload ocUpload = new OCUpload(file.getAbsolutePath(), remotePath, account.name); + + assertTrue( + new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + getStorageManager() + ) + .setRemoteFolderToBeCreated() + .execute(client) + .isSuccess() + ); + + long creationTimestamp = Files.readAttributes(file.toPath(), BasicFileAttributes.class) + .creationTime() + .to(TimeUnit.SECONDS); + + long uploadTimestamp = System.currentTimeMillis() / 1000; + + // RefreshFolderOperation + assertTrue(new RefreshFolderOperation(getStorageManager().getFileByDecryptedRemotePath("/"), + System.currentTimeMillis() / 1000, + false, + false, + getStorageManager(), + user, + targetContext).execute(client).isSuccess()); + + List files = getStorageManager().getFolderContent(getStorageManager().getFileByDecryptedRemotePath("/"), + false); + + OCFile ocFile = files.get(0); + + assertEquals(remotePath, ocFile.getRemotePath()); + assertEquals(creationTimestamp, ocFile.getCreationTimestamp()); + assertTrue(uploadTimestamp - 10 < ocFile.getUploadTimestamp() && + uploadTimestamp + 10 > ocFile.getUploadTimestamp()); + } + + @Test + public void testMetadata() throws IOException, AccountUtils.AccountNotFoundException { + testOnlyOnServer(NextcloudVersion.nextcloud_27); + + File file = getFile("gps.jpg"); + String remotePath = "/metadata.jpg"; + OCUpload ocUpload = new OCUpload(file.getAbsolutePath(), remotePath, account.name); + + assertTrue( + new UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + getStorageManager() + ) + .setRemoteFolderToBeCreated() + .execute(client) + .isSuccess() + ); + + // RefreshFolderOperation + assertTrue(new RefreshFolderOperation(getStorageManager().getFileByDecryptedRemotePath("/"), + System.currentTimeMillis() / 1000, + false, + false, + getStorageManager(), + user, + targetContext).execute(client).isSuccess()); + + List files = getStorageManager().getFolderContent(getStorageManager().getFileByDecryptedRemotePath("/"), + false); + + OCFile ocFile = null; + for (OCFile f : files) { + if ("metadata.jpg".equals(f.getFileName())) { + ocFile = f; + break; + } + } + + assertNotNull(ocFile); + assertEquals(remotePath, ocFile.getRemotePath()); + assertEquals(new GeoLocation(64, -46), ocFile.getGeoLocation()); + assertEquals(new ImageDimension(300f, 200f), ocFile.getImageDimension()); + } + + private void verifyStoragePath(OCFile file) { + assertEquals(FileStorageUtils.getSavePath(account.name) + FOLDER + file.getDecryptedFileName(), + file.getStoragePath()); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/authentication/AuthenticatorActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/authentication/AuthenticatorActivityIT.kt new file mode 100644 index 000000000000..0ed8b7fe7286 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/authentication/AuthenticatorActivityIT.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.authentication + +import android.graphics.Color +import org.junit.Assert +import org.junit.Test + +class AuthenticatorActivityIT { + @Test(expected = IndexOutOfBoundsException::class) + fun testException() { + Color.parseColor("") + } + + @Test + @Suppress("TooGenericExceptionCaught") + fun tryCatch() { + val color = try { + Color.parseColor("1") + } catch (e: Exception) { + Color.BLACK + } + + Assert.assertNotNull(color) + } + + @Test + @Suppress("TooGenericExceptionCaught") + fun tryCatch2() { + val color = try { + Color.parseColor("") + } catch (e: Exception) { + Color.BLACK + } + + Assert.assertNotNull(color) + } + + @Test + @Suppress("TooGenericExceptionCaught") + fun tryCatch3() { + val color = try { + Color.parseColor(null) + } catch (e: Exception) { + Color.BLACK + } + + Assert.assertNotNull(color) + } + + @Test + @Suppress("TooGenericExceptionCaught") + fun tryCatch4() { + val color = try { + Color.parseColor("abc") + } catch (e: Exception) { + Color.BLACK + } + + Assert.assertNotNull(color) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/authentication/PassCodeManagerIT.kt b/app/src/androidTest/java/com/owncloud/android/authentication/PassCodeManagerIT.kt new file mode 100644 index 000000000000..7d616c4bf9c6 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/authentication/PassCodeManagerIT.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.authentication + +import androidx.test.core.app.launchActivity +import com.nextcloud.client.core.Clock +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.test.TestActivity +import com.owncloud.android.ui.activity.SettingsActivity +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * This class should really be unit tests, but PassCodeManager needs a refactor + * to decouple the locking logic from the platform classes + */ +class PassCodeManagerIT { + @MockK + lateinit var appPreferences: AppPreferences + + @MockK + lateinit var clockImpl: Clock + + lateinit var sut: PassCodeManager + + @Before + fun before() { + MockKAnnotations.init(this, relaxed = true) + sut = PassCodeManager(appPreferences, clockImpl) + } + + @Test + fun testResumeDuplicateActivity() { + // set locked state + every { appPreferences.lockPreference } returns SettingsActivity.LOCK_PASSCODE + every { appPreferences.lockTimestamp } returns 200 + every { clockImpl.millisSinceBoot } returns 10000 + + launchActivity().use { scenario -> + scenario.onActivity { activity -> + // resume activity twice + var askedForPin = sut.onActivityResumed(activity) + assertTrue("Passcode not requested on first launch", askedForPin) + sut.onActivityResumed(activity) + + // stop it once + sut.onActivityStopped(activity) + + // resume again. should ask for passcode + askedForPin = sut.onActivityResumed(activity) + assertTrue("Passcode not requested on subsequent launch after stop", askedForPin) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/ArbitraryDataProviderIT.kt b/app/src/androidTest/java/com/owncloud/android/datamodel/ArbitraryDataProviderIT.kt new file mode 100644 index 000000000000..6188d5eea1c6 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/ArbitraryDataProviderIT.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import com.owncloud.android.AbstractIT +import org.junit.Assert.assertEquals +import org.junit.Test + +class ArbitraryDataProviderIT : AbstractIT() { + + @Test + fun testEmpty() { + val key = "DUMMY_KEY" + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, key, "") + + assertEquals("", arbitraryDataProvider.getValue(user.accountName, key)) + } + + @Test + fun testString() { + val key = "DUMMY_KEY" + var value = "123" + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, key, value) + assertEquals(value, arbitraryDataProvider.getValue(user.accountName, key)) + + value = "" + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, key, value) + assertEquals(value, arbitraryDataProvider.getValue(user.accountName, key)) + + value = "-1" + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, key, value) + assertEquals(value, arbitraryDataProvider.getValue(user.accountName, key)) + } + + @Test + fun testBoolean() { + val key = "DUMMY_KEY" + var value = true + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, key, value) + assertEquals(value, arbitraryDataProvider.getBooleanValue(user.accountName, key)) + + value = false + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, key, value) + assertEquals(value, arbitraryDataProvider.getBooleanValue(user.accountName, key)) + } + + @Test + fun testInteger() { + val key = "DUMMY_KEY" + var value = 1 + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, key, value.toString()) + assertEquals(value, arbitraryDataProvider.getIntegerValue(user.accountName, key)) + + value = -1 + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, key, value.toString()) + assertEquals(value, arbitraryDataProvider.getIntegerValue(user.accountName, key)) + } + + @Test + fun testIncrement() { + val key = "INCREMENT" + + // key does not exist + assertEquals(-1, arbitraryDataProvider.getIntegerValue(user.accountName, key)) + + // increment -> 1 + arbitraryDataProvider.incrementValue(user.accountName, key) + assertEquals(1, arbitraryDataProvider.getIntegerValue(user.accountName, key)) + + // increment -> 2 + arbitraryDataProvider.incrementValue(user.accountName, key) + assertEquals(2, arbitraryDataProvider.getIntegerValue(user.accountName, key)) + + // delete + arbitraryDataProvider.deleteKeyForAccount(user.accountName, key) + assertEquals(-1, arbitraryDataProvider.getIntegerValue(user.accountName, key)) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/ContentResolverHelperIT.kt b/app/src/androidTest/java/com/owncloud/android/datamodel/ContentResolverHelperIT.kt new file mode 100644 index 000000000000..0c86e7a7ecd0 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/ContentResolverHelperIT.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Álvaro Brey + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.content.ContentResolver +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.argThat +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class ContentResolverHelperIT { + + companion object { + private val URI = Uri.parse("http://foo.bar") + private val PROJECTION = arrayOf("Foo") + private const val SELECTION = "selection" + private const val SORT_COLUMN = "sortColumn" + private const val SORT_DIRECTION = ContentResolverHelper.SORT_DIRECTION_ASCENDING + private const val SORT_DIRECTION_INT = ContentResolver.QUERY_SORT_DIRECTION_ASCENDING + private const val LIMIT = 10 + } + + @Mock + lateinit var resolver: ContentResolver + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + } + + @Test + fun contentResolver_onAndroid26_usesNewAPI() { + ContentResolverHelper + .queryResolver(resolver, URI, PROJECTION, SELECTION, null, SORT_COLUMN, SORT_DIRECTION, LIMIT) + + verify(resolver).query( + eq(URI), + eq(PROJECTION), + argThat { bundle -> + bundle.getString(ContentResolver.QUERY_ARG_SQL_SELECTION) == SELECTION && + bundle.getInt(ContentResolver.QUERY_ARG_LIMIT) == LIMIT && + bundle.getStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS)!! + .contentEquals(arrayOf(SORT_COLUMN)) && + bundle.getInt(ContentResolver.QUERY_ARG_SORT_DIRECTION) == SORT_DIRECTION_INT + }, + null + ) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/Credentials.kt b/app/src/androidTest/java/com/owncloud/android/datamodel/Credentials.kt new file mode 100644 index 000000000000..5bcaf7c77141 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/Credentials.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel + +data class Credentials(val publicKey: String, val certificate: String) diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerContentProviderClientIT.java b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerContentProviderClientIT.java new file mode 100644 index 000000000000..76c576c6af5f --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerContentProviderClientIT.java @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel; + +import com.owncloud.android.db.ProviderMeta; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class FileDataStorageManagerContentProviderClientIT extends FileDataStorageManagerIT { + public void before() { + sut = new FileDataStorageManager(user, + targetContext + .getContentResolver() + .acquireContentProviderClient(ProviderMeta.ProviderTableMeta.CONTENT_URI) + ); + + super.before(); + } + + @Test + public void saveFile() { + + String path = "/1.txt"; + OCFile file = new OCFile(path); + file.setRemoteId("00000008ocjycgrudn78"); + + // TODO check via reflection that every parameter is set + + file.setFileLength(1024000); + file.setModificationTimestamp(1582019340); + sut.saveNewFile(file); + + + OCFile read = sut.getFileByPath(path); + assertNotNull(read); + + assertEquals(file.getRemotePath(), read.getRemotePath()); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerContentResolverIT.kt b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerContentResolverIT.kt new file mode 100644 index 000000000000..6c800da513e1 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerContentResolverIT.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import org.junit.Assert +import org.junit.Test + +class FileDataStorageManagerContentResolverIT : FileDataStorageManagerIT() { + companion object { + private const val MANY_FILES_AMOUNT = 5000 + } + + override fun before() { + sut = FileDataStorageManager(user, targetContext.contentResolver) + super.before() + } + + /** + * only on FileDataStorageManager + */ + @Test + fun testFolderWithManyFiles() { + // create folder + val folderA = OCFile("/folderA/") + folderA.setFolder().parentId = sut.getFileByDecryptedRemotePath("/")!!.fileId + sut.saveFile(folderA) + Assert.assertTrue(sut.fileExists("/folderA/")) + Assert.assertEquals(0, sut.getFolderContent(folderA, false).size) + val folderAId = sut.getFileByDecryptedRemotePath("/folderA/")!!.fileId + + // create files + val newFiles = (1..MANY_FILES_AMOUNT).map { + val file = OCFile("/folderA/file$it") + file.parentId = folderAId + sut.saveFile(file) + + val storedFile = sut.getFileByDecryptedRemotePath("/folderA/file$it") + Assert.assertNotNull(storedFile) + storedFile + } + + // save files in folder + sut.saveFolder( + folderA, + newFiles, + ArrayList() + ) + // check file count is correct + Assert.assertEquals(MANY_FILES_AMOUNT, sut.getFolderContent(folderA, false).size) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java new file mode 100644 index 000000000000..f8b04338182d --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java @@ -0,0 +1,356 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel; + +import android.content.ContentValues; + +import com.owncloud.android.AbstractOnServerIT; +import com.owncloud.android.db.ProviderMeta; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.CreateFolderRemoteOperation; +import com.owncloud.android.lib.resources.files.SearchRemoteOperation; +import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.lib.resources.status.CapabilityBooleanType; +import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.operations.RefreshFolderOperation; +import com.owncloud.android.utils.FileStorageUtils; + +import junit.framework.TestCase; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static com.owncloud.android.lib.resources.files.SearchRemoteOperation.SearchType.GALLERY_SEARCH; +import static com.owncloud.android.lib.resources.files.SearchRemoteOperation.SearchType.PHOTO_SEARCH; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +abstract public class FileDataStorageManagerIT extends AbstractOnServerIT { + + protected FileDataStorageManager sut; + private OCCapability capability; + + @Before + public void before() { + // make sure everything is removed + sut.deleteAllFiles(); + sut.deleteVirtuals(VirtualFolderType.GALLERY); + + assertEquals(0, sut.getAllFiles().size()); + + capability = new GetCapabilitiesRemoteOperation(null).execute(client).getResultData(); + } + + @After + public void after() { + super.after(); + + sut.deleteAllFiles(); + sut.deleteVirtuals(VirtualFolderType.GALLERY); + } + + @Test + public void simpleTest() { + OCFile file = sut.getFileByDecryptedRemotePath("/"); + assertNotNull(file); + assertTrue(file.fileExists()); + assertNull(sut.getFileByDecryptedRemotePath("/123123")); + } + + @Test + public void getAllFiles_NoAvailable() { + assertEquals(0, sut.getAllFiles().size()); + } + + @Test + public void testFolderContent() throws IOException { + assertEquals(0, sut.getAllFiles().size()); + assertTrue(new CreateFolderRemoteOperation("/1/1/", true).execute(client).isSuccess()); + + assertTrue(new CreateFolderRemoteOperation("/1/2/", true).execute(client).isSuccess()); + + assertTrue(new UploadFileRemoteOperation(getDummyFile("chunkedFile.txt").getAbsolutePath(), + "/1/1/chunkedFile.txt", + "text/plain", + System.currentTimeMillis() / 1000) + .execute(client).isSuccess()); + + assertTrue(new UploadFileRemoteOperation(getDummyFile("chunkedFile.txt").getAbsolutePath(), + "/1/1/chunkedFile2.txt", + "text/plain", + System.currentTimeMillis() / 1000) + .execute(client).isSuccess()); + + File imageFile = getFile("imageFile.png"); + assertTrue(new UploadFileRemoteOperation(imageFile.getAbsolutePath(), + "/1/1/imageFile.png", + "image/png", + System.currentTimeMillis() / 1000) + .execute(client).isSuccess()); + + // sync + assertNull(sut.getFileByDecryptedRemotePath("/1/1/")); + + assertTrue(new RefreshFolderOperation(sut.getFileByDecryptedRemotePath("/"), + System.currentTimeMillis() / 1000, + false, + false, + sut, + user, + targetContext).execute(client).isSuccess()); + + assertTrue(new RefreshFolderOperation(sut.getFileByDecryptedRemotePath("/1/"), + System.currentTimeMillis() / 1000, + false, + false, + sut, + user, + targetContext).execute(client).isSuccess()); + + assertTrue(new RefreshFolderOperation(sut.getFileByDecryptedRemotePath("/1/1/"), + System.currentTimeMillis() / 1000, + false, + false, + sut, + user, + targetContext).execute(client).isSuccess()); + + assertEquals(3, sut.getFolderContent(sut.getFileByDecryptedRemotePath("/1/1/"), false).size()); + } + + /** + * This test creates an image, does a photo search (now returned image is not yet in file hierarchy), then root + * folder is refreshed and it is verified that the same image file is used in database + */ + @Test + public void testPhotoSearch() throws IOException { + String remotePath = "/imageFile.png"; + VirtualFolderType virtualType = VirtualFolderType.GALLERY; + + assertEquals(0, sut.getFolderContent(sut.getFileByDecryptedRemotePath("/"), false).size()); + assertEquals(1, sut.getAllFiles().size()); + + File imageFile = getFile("imageFile.png"); + assertTrue(new UploadFileRemoteOperation(imageFile.getAbsolutePath(), + remotePath, + "image/png", + System.currentTimeMillis() / 1000) + .execute(client).isSuccess()); + + assertNull(sut.getFileByDecryptedRemotePath(remotePath)); + + // search + SearchRemoteOperation searchRemoteOperation = new SearchRemoteOperation("image/%", + PHOTO_SEARCH, + false, + capability); + + RemoteOperationResult> searchResult = searchRemoteOperation.execute(client); + TestCase.assertTrue(searchResult.isSuccess()); + TestCase.assertEquals(1, searchResult.getResultData().size()); + + OCFile ocFile = FileStorageUtils.fillOCFile(searchResult.getResultData().get(0)); + sut.saveFile(ocFile); + + List contentValues = new ArrayList<>(); + ContentValues cv = new ContentValues(); + cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, virtualType.toString()); + cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_OCFILE_ID, ocFile.getFileId()); + + contentValues.add(cv); + + sut.saveVirtuals(contentValues); + + assertEquals(remotePath, ocFile.getRemotePath()); + + assertEquals(0, sut.getFolderContent(sut.getFileByDecryptedRemotePath("/"), false).size()); + + assertEquals(1, sut.getVirtualFolderContent(virtualType, false).size()); + assertEquals(2, sut.getAllFiles().size()); + + // update root + assertTrue(new RefreshFolderOperation(sut.getFileByDecryptedRemotePath("/"), + System.currentTimeMillis() / 1000, + false, + false, + sut, + user, + targetContext).execute(client).isSuccess()); + + + assertEquals(1, sut.getFolderContent(sut.getFileByDecryptedRemotePath("/"), false).size()); + assertEquals(1, sut.getVirtualFolderContent(virtualType, false).size()); + assertEquals(2, sut.getAllFiles().size()); + + assertEquals(sut.getVirtualFolderContent(virtualType, false).get(0), + sut.getFolderContent(sut.getFileByDecryptedRemotePath("/"), false).get(0)); + } + + /** + * This test creates an image and a video, does a gallery search (now returned image and video is not yet in file + * hierarchy), then root folder is refreshed and it is verified that the same image file is used in database + */ + @Test + public void testGallerySearch() throws IOException { + sut = new FileDataStorageManager(user, + targetContext + .getContentResolver() + .acquireContentProviderClient(ProviderMeta.ProviderTableMeta.CONTENT_URI) + ); + + String imagePath = "/imageFile.png"; + VirtualFolderType virtualType = VirtualFolderType.GALLERY; + + assertEquals(0, sut.getFolderContent(sut.getFileByDecryptedRemotePath("/"), false).size()); + assertEquals(1, sut.getAllFiles().size()); + + File imageFile = getFile("imageFile.png"); + assertTrue(new UploadFileRemoteOperation(imageFile.getAbsolutePath(), + imagePath, + "image/png", + (System.currentTimeMillis() - 10000) / 1000) + .execute(client).isSuccess()); + + // Check that file does not yet exist in local database + assertNull(sut.getFileByDecryptedRemotePath(imagePath)); + + String videoPath = "/videoFile.mp4"; + File videoFile = getFile("videoFile.mp4"); + assertTrue(new UploadFileRemoteOperation(videoFile.getAbsolutePath(), + videoPath, + "video/mpeg", + (System.currentTimeMillis() + 10000) / 1000) + .execute(client).isSuccess()); + + // Check that file does not yet exist in local database + assertNull(sut.getFileByDecryptedRemotePath(videoPath)); + + // search + SearchRemoteOperation searchRemoteOperation = new SearchRemoteOperation("", + GALLERY_SEARCH, + false, + capability); + + RemoteOperationResult> searchResult = searchRemoteOperation.execute(client); + TestCase.assertTrue(searchResult.isSuccess()); + TestCase.assertEquals(2, searchResult.getResultData().size()); + + // newest file must be video path (as sorted by recently modified) + OCFile ocFile = FileStorageUtils.fillOCFile( searchResult.getResultData().get(0)); + sut.saveFile(ocFile); + assertEquals(videoPath, ocFile.getRemotePath()); + + List contentValues = new ArrayList<>(); + ContentValues cv = new ContentValues(); + cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, virtualType.toString()); + cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_OCFILE_ID, ocFile.getFileId()); + + contentValues.add(cv); + + // second is image file, as older + OCFile ocFile2 = FileStorageUtils.fillOCFile(searchResult.getResultData().get(1)); + sut.saveFile(ocFile2); + assertEquals(imagePath, ocFile2.getRemotePath()); + + ContentValues cv2 = new ContentValues(); + cv2.put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, virtualType.toString()); + cv2.put(ProviderMeta.ProviderTableMeta.VIRTUAL_OCFILE_ID, ocFile2.getFileId()); + + contentValues.add(cv2); + + sut.saveVirtuals(contentValues); + + assertEquals(0, sut.getFolderContent(sut.getFileByDecryptedRemotePath("/"), false).size()); + + assertEquals(2, sut.getVirtualFolderContent(virtualType, false).size()); + assertEquals(3, sut.getAllFiles().size()); + + // update root + assertTrue(new RefreshFolderOperation(sut.getFileByDecryptedRemotePath("/"), + System.currentTimeMillis() / 1000, + false, + false, + sut, + user, + targetContext).execute(client).isSuccess()); + + + assertEquals(2, sut.getFolderContent(sut.getFileByDecryptedRemotePath("/"), false).size()); + assertEquals(2, sut.getVirtualFolderContent(virtualType, false).size()); + assertEquals(3, sut.getAllFiles().size()); + + assertEquals(sut.getVirtualFolderContent(virtualType, false).get(0), + sut.getFolderContent(sut.getFileByDecryptedRemotePath("/"), false).get(0)); + } + + @Test + public void testSaveNewFile() { + assertTrue(new CreateFolderRemoteOperation("/1/1/", true).execute(client).isSuccess()); + + assertTrue(new RefreshFolderOperation(sut.getFileByDecryptedRemotePath("/"), + System.currentTimeMillis() / 1000, + false, + false, + sut, + user, + targetContext).execute(client).isSuccess()); + + assertTrue(new RefreshFolderOperation(sut.getFileByDecryptedRemotePath("/1/"), + System.currentTimeMillis() / 1000, + false, + false, + sut, + user, + targetContext).execute(client).isSuccess()); + + assertTrue(new RefreshFolderOperation(sut.getFileByDecryptedRemotePath("/1/1/"), + System.currentTimeMillis() / 1000, + false, + false, + sut, + user, + targetContext).execute(client).isSuccess()); + + OCFile newFile = new OCFile("/1/1/1.txt"); + newFile.setRemoteId("12345678"); + + sut.saveNewFile(newFile); + } + + @Test(expected = IllegalArgumentException.class) + public void testSaveNewFile_NonExistingParent() { + assertTrue(new CreateFolderRemoteOperation("/1/1/", true).execute(client).isSuccess()); + + OCFile newFile = new OCFile("/1/1/1.txt"); + + sut.saveNewFile(newFile); + } + + @Test + public void testOCCapability() { + OCCapability capability = new OCCapability(); + capability.setUserStatus(CapabilityBooleanType.TRUE); + + sut.saveCapabilities(capability); + + OCCapability newCapability = sut.getCapability(user); + + assertEquals(capability.getUserStatus(), newCapability.getUserStatus()); + } + +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/OCCapabilityIT.kt b/app/src/androidTest/java/com/owncloud/android/datamodel/OCCapabilityIT.kt new file mode 100644 index 000000000000..121d630e87b8 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/OCCapabilityIT.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import com.owncloud.android.AbstractIT +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.OCCapability +import org.junit.Assert.assertEquals +import org.junit.Test + +class OCCapabilityIT : AbstractIT() { + @Test + fun saveCapability() { + val fileDataStorageManager = FileDataStorageManager(user, targetContext.contentResolver) + + val capability = OCCapability() + capability.etag = "123" + capability.userStatus = CapabilityBooleanType.TRUE + capability.userStatusSupportsEmoji = CapabilityBooleanType.TRUE + capability.dropAccount = CapabilityBooleanType.TRUE + + fileDataStorageManager.saveCapabilities(capability) + + val newCapability = fileDataStorageManager.getCapability(user.accountName) + + assertEquals(capability.etag, newCapability.etag) + assertEquals(capability.userStatus, newCapability.userStatus) + assertEquals(capability.userStatusSupportsEmoji, newCapability.userStatusSupportsEmoji) + assertEquals(capability.dropAccount, newCapability.dropAccount) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/OCFileIconTests.kt b/app/src/androidTest/java/com/owncloud/android/datamodel/OCFileIconTests.kt new file mode 100644 index 000000000000..59167c69d76f --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/OCFileIconTests.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import com.owncloud.android.R +import com.owncloud.android.lib.common.network.WebdavEntry.MountType +import org.junit.After +import org.junit.Before +import org.junit.Test + +class OCFileIconTests { + + private val path = "/path/to/a/file.txt" + private var sut: OCFile? = null + + @Before + fun setup() { + sut = OCFile(path) + } + + @Test + fun testGetFileOverlayIconWhenFileIsAutoUploadFolderShouldReturnFolderOverlayUploadIcon() { + val fileOverlayIcon = sut?.getFileOverlayIconId(true) + val expectedDrawable = R.drawable.ic_folder_overlay_upload + assert(fileOverlayIcon == expectedDrawable) + } + + @Test + fun testGetFileOverlayIconWhenFileIsEncryptedShouldReturnFolderOverlayKeyIcon() { + sut?.isEncrypted = true + val fileOverlayIcon = sut?.getFileOverlayIconId(false) + val expectedDrawable = R.drawable.ic_folder_overlay_key + assert(fileOverlayIcon == expectedDrawable) + } + + @Test + fun testGetFileOverlayIconWhenFileIsGroupFolderShouldReturnFolderOverlayAccountGroupIcon() { + sut?.mountType = MountType.GROUP + val fileOverlayIcon = sut?.getFileOverlayIconId(false) + val expectedDrawable = R.drawable.ic_folder_overlay_account_group + assert(fileOverlayIcon == expectedDrawable) + } + + @Test + fun testGetFileOverlayIconWhenFileIsSharedViaLinkShouldReturnFolderOverlayLinkIcon() { + sut?.isSharedViaLink = true + val fileOverlayIcon = sut?.getFileOverlayIconId(false) + val expectedDrawable = R.drawable.ic_folder_overlay_link + assert(fileOverlayIcon == expectedDrawable) + } + + @Test + fun testGetFileOverlayIconWhenFileIsSharedShouldReturnFolderOverlayShareIcon() { + sut?.isSharedWithSharee = true + val fileOverlayIcon = sut?.getFileOverlayIconId(false) + val expectedDrawable = R.drawable.ic_folder_overlay_share + assert(fileOverlayIcon == expectedDrawable) + } + + @Test + fun testGetFileOverlayIconWhenFileIsExternalShouldReturnFolderOverlayExternalIcon() { + sut?.mountType = MountType.EXTERNAL + val fileOverlayIcon = sut?.getFileOverlayIconId(false) + val expectedDrawable = R.drawable.ic_folder_overlay_external + assert(fileOverlayIcon == expectedDrawable) + } + + @Test + fun testGetFileOverlayIconWhenFileIsLockedShouldReturnFolderOverlayLockIcon() { + sut?.isLocked = true + val fileOverlayIcon = sut?.getFileOverlayIconId(false) + val expectedDrawable = R.drawable.ic_folder_overlay_lock + assert(fileOverlayIcon == expectedDrawable) + } + + @Test + fun testGetFileOverlayIconWhenFileIsFolderShouldReturnNull() { + val fileOverlayIcon = sut?.getFileOverlayIconId(false) + assert(fileOverlayIcon == null) + } + + @After + fun destroy() { + sut = null + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/OCFileUnitTest.java b/app/src/androidTest/java/com/owncloud/android/datamodel/OCFileUnitTest.java new file mode 100644 index 000000000000..54efa0cd7dca --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/OCFileUnitTest.java @@ -0,0 +1,116 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2016 David A. Velasco + * SPDX-FileCopyrightText: 2016 2016 ownCloud Inc + * SPDX-License-Identifier: GPL-2.0-only + */ +package com.owncloud.android.datamodel; + +import android.os.Parcel; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + + +/** + * Instrumented unit test, to be run in an Android emulator or device. + * At the moment, it's a sample to validate the automatic test environment, in the scope of instrumented unit tests. + * Don't take it as an example of completeness. + * See http://developer.android.com/intl/es/training/testing/unit-testing/instrumented-unit-tests.html . + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class OCFileUnitTest { + + private final static String PATH = "/path/to/a/file.txt"; + private static final long ID = 12345L; + private static final long PARENT_ID = 567890L; + private static final String STORAGE_PATH = "/mnt/sd/localpath/to/a/file.txt"; + private static final String MIME_TYPE = "text/plain"; + private static final long FILE_LENGTH = 9876543210L; + private static final long UPLOADED_TIMESTAMP = 8765431109L; + private static final long CREATION_TIMESTAMP = 8765432109L; + private static final long MODIFICATION_TIMESTAMP = 7654321098L; + private static final long MODIFICATION_TIMESTAMP_AT_LAST_SYNC_FOR_DATA = 6543210987L; + private static final long LAST_SYNC_DATE_FOR_PROPERTIES = 5432109876L; + private static final long LAST_SYNC_DATE_FOR_DATA = 4321098765L; + private static final String ETAG = "adshfas98ferqw8f9yu2"; + private static final String PUBLIC_LINK = "https://nextcloud.localhost/owncloud/987427448712984sdas29"; + private static final String PERMISSIONS = "SRKNVD"; + private static final String REMOTE_ID = "jadñgiadf8203:9jrp98v2mn3er2089fh"; + private static final String ETAG_IN_CONFLICT = "2adshfas98ferqw8f9yu"; + + private OCFile mFile; + + @Before + public void createDefaultOCFile() { + mFile = new OCFile(PATH); + } + + @Test + public void writeThenReadAsParcelable() { + + // Set up mFile with not-default values + mFile.setFileId(ID); + mFile.setParentId(PARENT_ID); + mFile.setStoragePath(STORAGE_PATH); + mFile.setMimeType(MIME_TYPE); + mFile.setFileLength(FILE_LENGTH); + mFile.setUploadTimestamp(UPLOADED_TIMESTAMP); + mFile.setCreationTimestamp(CREATION_TIMESTAMP); + mFile.setModificationTimestamp(MODIFICATION_TIMESTAMP); + mFile.setModificationTimestampAtLastSyncForData(MODIFICATION_TIMESTAMP_AT_LAST_SYNC_FOR_DATA); + mFile.setLastSyncDateForProperties(LAST_SYNC_DATE_FOR_PROPERTIES); + mFile.setLastSyncDateForData(LAST_SYNC_DATE_FOR_DATA); + mFile.setEtag(ETAG); + mFile.setSharedViaLink(true); + mFile.setSharedWithSharee(true); + mFile.setPermissions(PERMISSIONS); + mFile.setRemoteId(REMOTE_ID); + mFile.setUpdateThumbnailNeeded(true); + mFile.setDownloading(true); + mFile.setEtagInConflict(ETAG_IN_CONFLICT); + + + // Write the file data in a Parcel + Parcel parcel = Parcel.obtain(); + mFile.writeToParcel(parcel, mFile.describeContents()); + + // Read the data from the parcel + parcel.setDataPosition(0); + OCFile fileReadFromParcel = OCFile.CREATOR.createFromParcel(parcel); + + // Verify that the received data are correct + assertThat(fileReadFromParcel.getRemotePath(), is(PATH)); + assertThat(fileReadFromParcel.getFileId(), is(ID)); + assertThat(fileReadFromParcel.getParentId(), is(PARENT_ID)); + assertThat(fileReadFromParcel.getStoragePath(), is(STORAGE_PATH)); + assertThat(fileReadFromParcel.getMimeType(), is(MIME_TYPE)); + assertThat(fileReadFromParcel.getFileLength(), is(FILE_LENGTH)); + assertThat(fileReadFromParcel.getUploadTimestamp(), is(UPLOADED_TIMESTAMP)); + assertThat(fileReadFromParcel.getCreationTimestamp(), is(CREATION_TIMESTAMP)); + assertThat(fileReadFromParcel.getModificationTimestamp(), is(MODIFICATION_TIMESTAMP)); + assertThat( + fileReadFromParcel.getModificationTimestampAtLastSyncForData(), + is(MODIFICATION_TIMESTAMP_AT_LAST_SYNC_FOR_DATA) + ); + assertThat(fileReadFromParcel.getLastSyncDateForProperties(), is(LAST_SYNC_DATE_FOR_PROPERTIES)); + assertThat(fileReadFromParcel.getLastSyncDateForData(), is(LAST_SYNC_DATE_FOR_DATA)); + assertThat(fileReadFromParcel.getEtag(), is(ETAG)); + assertThat(fileReadFromParcel.isSharedViaLink(), is(true)); + assertThat(fileReadFromParcel.isSharedWithSharee(), is(true)); + assertThat(fileReadFromParcel.getPermissions(), is(PERMISSIONS)); + assertThat(fileReadFromParcel.getRemoteId(), is(REMOTE_ID)); + assertThat(fileReadFromParcel.isUpdateThumbnailNeeded(), is(true)); + assertThat(fileReadFromParcel.isDownloading(), is(true)); + assertThat(fileReadFromParcel.getEtagInConflict(), is(ETAG_IN_CONFLICT)); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.kt b/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.kt new file mode 100644 index 000000000000..dd1b256c86d6 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.kt @@ -0,0 +1,239 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 JARP + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ActivityNotFoundException +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.client.database.entity.toUploadEntity +import com.nextcloud.test.RandomStringGenerator.make +import com.owncloud.android.AbstractIT +import com.owncloud.android.MainApp +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.operations.UploadFileOperation +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import java.io.File +import java.util.Random +import java.util.UUID +import java.util.function.Supplier + +/** + * Created by JARP on 6/7/17. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class UploadStorageManagerTest : AbstractIT() { + private lateinit var uploadsStorageManager: UploadsStorageManager + + @Mock + private lateinit var currentAccountProvider: CurrentAccountProvider + + private lateinit var userAccountManager: UserAccountManager + + private lateinit var user2: User + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + val instrumentationCtx = ApplicationProvider.getApplicationContext() + val contentResolver = instrumentationCtx.contentResolver + uploadsStorageManager = UploadsStorageManager(currentAccountProvider, contentResolver) + userAccountManager = UserAccountManagerImpl.fromContext(targetContext) + + val temp = Account("test2@test.com", MainApp.getAccountType(targetContext)) + if (!userAccountManager.exists(temp)) { + val platformAccountManager = AccountManager.get(targetContext) + platformAccountManager.addAccountExplicitly(temp, "testPassword", null) + platformAccountManager.setUserData( + temp, + AccountUtils.Constants.KEY_OC_ACCOUNT_VERSION, + UserAccountManager.ACCOUNT_VERSION.toString() + ) + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_VERSION, "14.0.0.0") + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_BASE_URL, "test.com") + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_USER_ID, "test") // same as userId + } + + val userAccountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext) + user2 = userAccountManager.getUser("test2@test.com") + .orElseThrow(Supplier { ActivityNotFoundException() }) + } + + @Test + fun testDeleteAllUploads() { + // Clean + for (user in userAccountManager.getAllUsers()) { + uploadsStorageManager.removeUserUploads(user) + } + val accountRowsA = 3 + val accountRowsB = 4 + insertUploads(account, accountRowsA) + insertUploads(user2.toPlatformAccount(), accountRowsB) + + Assert.assertEquals( + "Expected 4 removed uploads files", + 4, + uploadsStorageManager.removeUserUploads(user2).toLong() + ) + } + + @Test + fun largeTest() { + val size = 3000 + val uploads = ArrayList() + + deleteAllUploads() + Assert.assertEquals(0, uploadsStorageManager.getAllStoredUploads().size.toLong()) + + for (i in 0.., storedUpload: OCUpload): Boolean { + for (i in uploads.indices) { + if (storedUpload.isSame(uploads.get(i), true)) { + return true + } + } + return false + } + + @Test(expected = NullPointerException::class) + fun corruptedUpload() { + val corruptUpload = OCUpload( + File.separator + "LocalPath", + OCFile.PATH_SEPARATOR + "RemotePath", + account.name + ) + + corruptUpload.localPath = null + uploadsStorageManager.uploadDao.insertOrReplace(corruptUpload.toUploadEntity()) + uploadsStorageManager.getAllStoredUploads() + } + + @Test + fun getById() { + val upload = createUpload(account) + val id = uploadsStorageManager.uploadDao.insertOrReplace(upload.toUploadEntity()) + val newUpload = uploadsStorageManager.getUploadById(id) + + Assert.assertNotNull(newUpload) + Assert.assertEquals(upload.localAction.toLong(), newUpload!!.localAction.toLong()) + Assert.assertEquals(upload.folderUnlockToken, newUpload.folderUnlockToken) + } + + @Test + fun getByIdNull() { + val newUpload = uploadsStorageManager.getUploadById(-1) + Assert.assertNull(newUpload) + } + + private fun insertUploads(account: Account, rowsToInsert: Int) { + for (i in 0.. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.files + +import androidx.test.core.app.launchActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.nextcloud.client.account.User +import com.nextcloud.client.jobs.download.FileDownloadWorker +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.test.TestActivity +import com.nextcloud.utils.EditorUtils +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.files.model.FileLockType +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.services.OperationsService +import com.owncloud.android.ui.activity.ComponentsGetter +import com.owncloud.android.utils.MimeType +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.security.SecureRandom + +@RunWith(AndroidJUnit4::class) +class FileMenuFilterIT : AbstractIT() { + + @MockK + private lateinit var mockComponentsGetter: ComponentsGetter + + @MockK + private lateinit var mockStorageManager: FileDataStorageManager + + @MockK + private lateinit var mockFileUploaderBinder: FileUploadHelper + + @MockK + private lateinit var mockFileDownloadProgressListener: FileDownloadWorker.FileDownloadProgressListener + + @MockK + private lateinit var mockOperationsServiceBinder: OperationsService.OperationsServiceBinder + + @MockK + private lateinit var mockArbitraryDataProvider: ArbitraryDataProvider + + private lateinit var editorUtils: EditorUtils + + @Before + fun setup() { + MockKAnnotations.init(this) + every { mockFileUploaderBinder.isUploading(any(), any()) } returns false + every { mockComponentsGetter.fileUploaderHelper } returns mockFileUploaderBinder + every { mockFileDownloadProgressListener.isDownloading(any(), any()) } returns false + every { mockComponentsGetter.fileDownloadProgressListener } returns mockFileDownloadProgressListener + every { mockOperationsServiceBinder.isSynchronizing(any(), any()) } returns false + every { mockComponentsGetter.operationsServiceBinder } returns mockOperationsServiceBinder + every { mockStorageManager.getFileById(any()) } returns OCFile("/") + every { mockStorageManager.getFolderContent(any(), any()) } returns ArrayList() + every { mockArbitraryDataProvider.getValue(any(), any()) } returns "" + editorUtils = EditorUtils(mockArbitraryDataProvider) + } + + @Test + fun filter_noLockingCapability_lockItemsInvisible() { + val capability = OCCapability().apply { + endToEndEncryption = CapabilityBooleanType.UNKNOWN + } + + val file = OCFile("/foo.md") + + testLockingVisibilities( + capability, + file, + ExpectedLockVisibilities(lockFile = false, unlockFile = false) + ) + } + + @Test + fun filter_lockingCapability_fileUnlocked_lockVisible() { + val capability = OCCapability().apply { + endToEndEncryption = CapabilityBooleanType.UNKNOWN + filesLockingVersion = "1.0" + } + + val file = OCFile("/foo.md") + + testLockingVisibilities( + capability, + file, + ExpectedLockVisibilities(lockFile = true, unlockFile = false) + ) + } + + @Test + fun filter_lockingCapability_fileLocked_lockedByAndProps() { + val capability = OCCapability().apply { + endToEndEncryption = CapabilityBooleanType.UNKNOWN + filesLockingVersion = "1.0" + } + + val file = OCFile("/foo.md").apply { + isLocked = true + lockType = FileLockType.MANUAL + lockOwnerId = user.accountName.split("@")[0] + lockOwnerDisplayName = "TEST" + lockTimestamp = 1000 // irrelevant + lockTimeout = 1000 // irrelevant + } + + testLockingVisibilities( + capability, + file, + ExpectedLockVisibilities(lockFile = false, unlockFile = true) + ) + } + + @Test + fun filter_lockingCapability_fileLockedByOthers_lockedByAndProps() { + val capability = OCCapability().apply { + endToEndEncryption = CapabilityBooleanType.UNKNOWN + filesLockingVersion = "1.0" + } + + val file = OCFile("/foo.md").apply { + isLocked = true + lockType = FileLockType.MANUAL + lockOwnerId = "A_DIFFERENT_USER" + lockOwnerDisplayName = "A_DIFFERENT_USER" + lockTimestamp = 1000 // irrelevant + lockTimeout = 1000 // irrelevant + } + testLockingVisibilities( + capability, + file, + ExpectedLockVisibilities(lockFile = false, unlockFile = false) + ) + } + + @Test + fun filter_unset_encryption() { + val capability = OCCapability().apply { + endToEndEncryption = CapabilityBooleanType.TRUE + } + + val encryptedFolder = OCFile("/encryptedFolder/").apply { + isEncrypted = true + mimeType = MimeType.DIRECTORY + fileLength = SecureRandom().nextLong() + } + + val encryptedEmptyFolder = OCFile("/encryptedFolder/").apply { + isEncrypted = true + mimeType = MimeType.DIRECTORY + } + + val normalFolder = OCFile("/folder/").apply { + mimeType = MimeType.DIRECTORY + fileLength = SecureRandom().nextLong() + } + + val normalEmptyFolder = OCFile("/folder/").apply { + mimeType = MimeType.DIRECTORY + } + + configureCapability(capability) + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = + FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + + var sut = filterFactory.newInstance(encryptedFolder, mockComponentsGetter, true, user) + var toHide = sut.getToHide(false) + + // encrypted folder, with content + assertTrue(toHide.contains(R.id.action_unset_encrypted)) + assertTrue(toHide.contains(R.id.action_encrypted)) + assertTrue(toHide.contains(R.id.action_remove_file)) + + // encrypted, but empty folder + sut = filterFactory.newInstance(encryptedEmptyFolder, mockComponentsGetter, true, user) + toHide = sut.getToHide(false) + + assertTrue(toHide.contains(R.id.action_unset_encrypted)) + assertTrue(toHide.contains(R.id.action_remove_file)) + assertTrue(toHide.contains(R.id.action_encrypted)) + + // regular folder, with content + sut = filterFactory.newInstance(normalFolder, mockComponentsGetter, true, user) + toHide = sut.getToHide(false) + + assertTrue(toHide.contains(R.id.action_unset_encrypted)) + assertTrue(toHide.contains(R.id.action_encrypted)) + assertFalse(toHide.contains(R.id.action_remove_file)) + + // regular folder, without content + sut = filterFactory.newInstance(normalEmptyFolder, mockComponentsGetter, true, user) + toHide = sut.getToHide(false) + + assertTrue(toHide.contains(R.id.action_unset_encrypted)) + assertFalse(toHide.contains(R.id.action_encrypted)) + assertFalse(toHide.contains(R.id.action_remove_file)) + } + } + } + + @Test + fun filter_stream() { + val capability = OCCapability().apply { + endToEndEncryption = CapabilityBooleanType.TRUE + } + + val encryptedVideo = OCFile("/e2e/1.mpg").apply { + isEncrypted = true + mimeType = "video/mpeg" + } + + val normalVideo = OCFile("/folder/2.mpg").apply { + mimeType = "video/mpeg" + fileLength = SecureRandom().nextLong() + } + + configureCapability(capability) + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = + FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + + var sut = filterFactory.newInstance(encryptedVideo, mockComponentsGetter, true, user) + var toHide = sut.getToHide(false) + + // encrypted video, with content + assertTrue(toHide.contains(R.id.action_stream_media)) + + // regular video, with content + sut = filterFactory.newInstance(normalVideo, mockComponentsGetter, true, user) + toHide = sut.getToHide(false) + + assertFalse(toHide.contains(R.id.action_stream_media)) + } + } + } + + @Test + fun filter_select_all() { + configureCapability(OCCapability()) + + // not in single file fragment -> multi selection is possible under certain circumstances + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + + val files = listOf(OCFile("/foo.bin"), OCFile("/bar.bin"), OCFile("/baz.bin")) + + // single file, not in multi selection + // *Select all* and *Deselect all* should stay hidden + var sut = filterFactory.newInstance(files.first(), mockComponentsGetter, true, user) + + var toHide = sut.getToHide(false) + assertTrue(toHide.contains(R.id.action_select_all_action_menu)) + assertTrue(toHide.contains(R.id.action_deselect_all_action_menu)) + + // multiple files, all selected in multi selection + // *Deselect all* shown, *Select all* not + sut = filterFactory.newInstance(files.size, files, mockComponentsGetter, false, user) + + toHide = sut.getToHide(false) + assertTrue(toHide.contains(R.id.action_select_all_action_menu)) + assertFalse(toHide.contains(R.id.action_deselect_all_action_menu)) + + // multiple files, all but one selected + // both *Select all* and *Deselect all* should be shown + sut = filterFactory.newInstance(files.size + 1, files, mockComponentsGetter, false, user) + + toHide = sut.getToHide(false) + assertFalse(toHide.contains(R.id.action_select_all_action_menu)) + assertFalse(toHide.contains(R.id.action_deselect_all_action_menu)) + } + } + } + + fun filter_select_all_singleFileFragment() { + configureCapability(OCCapability()) + + // in single file fragment (e.g. FileDetailFragment or PreviewImageFragment), selecting multiple files + // is not possible -> *Select all* and *Deselect all* options should be hidden + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + + val files = listOf(OCFile("/foo.bin"), OCFile("/bar.bin"), OCFile("/baz.bin")) + + // single file + var sut = filterFactory.newInstance(files.first(), mockComponentsGetter, true, user) + + var toHide = sut.getToHide(true) + assertTrue(toHide.contains(R.id.action_select_all_action_menu)) + assertTrue(toHide.contains(R.id.action_deselect_all_action_menu)) + + // multiple files, all selected + sut = filterFactory.newInstance(files.size, files, mockComponentsGetter, false, user) + + toHide = sut.getToHide(true) + assertTrue(toHide.contains(R.id.action_select_all_action_menu)) + assertTrue(toHide.contains(R.id.action_deselect_all_action_menu)) + + // multiple files, all but one selected + sut = filterFactory.newInstance(files.size + 1, files, mockComponentsGetter, false, user) + + toHide = sut.getToHide(true) + assertTrue(toHide.contains(R.id.action_select_all_action_menu)) + assertTrue(toHide.contains(R.id.action_deselect_all_action_menu)) + } + } + } + + private data class ExpectedLockVisibilities(val lockFile: Boolean, val unlockFile: Boolean) + + private fun configureCapability(capability: OCCapability) { + every { mockStorageManager.getCapability(any()) } returns capability + every { mockStorageManager.getCapability(any()) } returns capability + } + + private fun testLockingVisibilities( + capability: OCCapability, + file: OCFile, + expectedLockVisibilities: ExpectedLockVisibilities + ) { + configureCapability(capability) + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = + FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + val sut = filterFactory.newInstance(file, mockComponentsGetter, true, user) + + val toHide = sut.getToHide(false) + + assertEquals( + expectedLockVisibilities.lockFile, + !toHide.contains(R.id.action_lock_file) + ) + assertEquals( + expectedLockVisibilities.unlockFile, + !toHide.contains(R.id.action_unlock_file) + ) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt b/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt new file mode 100644 index 000000000000..00c568d506ad --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt @@ -0,0 +1,490 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.files.services + +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.client.device.BatteryStatus +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.network.Connectivity +import com.nextcloud.client.network.ConnectivityService +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.operations.OperationCancelledException +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.UploadFileOperation +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertFalse +import junit.framework.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +abstract class FileUploaderIT : AbstractOnServerIT() { + private var uploadsStorageManager: UploadsStorageManager? = null + + private val connectivityServiceMock: ConnectivityService = object : ConnectivityService { + override fun isNetworkAndServerAvailable(callback: ConnectivityService.GenericCallback) = Unit + + override fun isConnected(): Boolean = false + + override fun isInternetWalled(): Boolean = false + override fun getConnectivity(): Connectivity = Connectivity.CONNECTED_WIFI + } + + private val powerManagementServiceMock: PowerManagementService = object : PowerManagementService { + override val isPowerSavingEnabled: Boolean + get() = false + + override val battery: BatteryStatus + get() = BatteryStatus() + } + + @Before + fun setUp() { + val contentResolver = targetContext.contentResolver + val accountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext) + uploadsStorageManager = UploadsStorageManager(accountManager, contentResolver) + } + + // /** + // * uploads a file, overwrites it with an empty one, check if overwritten + // */ + // disabled, flaky test + // @Test + // fun testKeepLocalAndOverwriteRemote() { + // val file = getDummyFile("chunkedFile.txt") + // val ocUpload = OCUpload(file.absolutePath, "/testFile.txt", account.name) + // + // assertTrue( + // UploadFileOperation( + // uploadsStorageManager, + // connectivityServiceMock, + // powerManagementServiceMock, + // user, + // null, + // ocUpload, + // FileUploader.NameCollisionPolicy.DEFAULT, + // FileUploader.LOCAL_BEHAVIOUR_COPY, + // targetContext, + // false, + // false + // ) + // .setRemoteFolderToBeCreated() + // .execute(client, storageManager) + // .isSuccess + // ) + // + // val result = ReadFileRemoteOperation("/testFile.txt").execute(client) + // assertTrue(result.isSuccess) + // + // assertEquals(file.length(), (result.data[0] as RemoteFile).length) + // + // val ocUpload2 = OCUpload(getDummyFile("empty.txt").absolutePath, "/testFile.txt", account.name) + // + // assertTrue( + // UploadFileOperation( + // uploadsStorageManager, + // connectivityServiceMock, + // powerManagementServiceMock, + // user, + // null, + // ocUpload2, + // FileUploader.NameCollisionPolicy.OVERWRITE, + // FileUploader.LOCAL_BEHAVIOUR_COPY, + // targetContext, + // false, + // false + // ) + // .execute(client, storageManager) + // .isSuccess + // ) + // + // val result2 = ReadFileRemoteOperation("/testFile.txt").execute(client) + // assertTrue(result2.isSuccess) + // + // assertEquals(0, (result2.data[0] as RemoteFile).length) + // } + + /** + * uploads a file, overwrites it with an empty one, check if overwritten + */ + @Test + fun testKeepLocalAndOverwriteRemoteStatic() { + val file = getDummyFile("chunkedFile.txt") + + FileUploadHelper().uploadNewFiles( + user, + arrayOf(file.absolutePath), + arrayOf("/testFile.txt"), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.DEFAULT + ) + + longSleep() + + val result = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result.isSuccess) + + assertEquals(file.length(), (result.data[0] as RemoteFile).length) + + val ocFile2 = OCFile("/testFile.txt") + ocFile2.storagePath = getDummyFile("empty.txt").absolutePath + + FileUploadHelper().uploadUpdatedFile( + user, + arrayOf(ocFile2), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + NameCollisionPolicy.OVERWRITE + ) + + shortSleep() + + val result2 = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result2.isSuccess) + + assertEquals(0, (result2.data[0] as RemoteFile).length) + } + + /** + * uploads a file, uploads another one with automatically (2) added, check + */ + @Test + fun testKeepBoth() { + var renameListenerWasTriggered = false + + val file = getDummyFile("chunkedFile.txt") + val ocUpload = OCUpload(file.absolutePath, "/testFile.txt", account.name) + + assertTrue( + UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + storageManager + ) + .setRemoteFolderToBeCreated() + .execute(client) + .isSuccess + ) + + val result = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result.isSuccess) + + assertEquals(file.length(), (result.data[0] as RemoteFile).length) + + val file2 = getDummyFile("empty.txt") + val ocUpload2 = OCUpload(file2.absolutePath, "/testFile.txt", account.name) + + assertTrue( + UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload2, + NameCollisionPolicy.RENAME, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + storageManager + ) + .addRenameUploadListener { + renameListenerWasTriggered = true + } + .execute(client) + .isSuccess + ) + + val result2 = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result2.isSuccess) + + assertEquals(file.length(), (result2.data[0] as RemoteFile).length) + + val result3 = ReadFileRemoteOperation("/testFile (2).txt").execute(client) + assertTrue(result3.isSuccess) + + assertEquals(file2.length(), (result3.data[0] as RemoteFile).length) + assertTrue(renameListenerWasTriggered) + } + + /** + * uploads a file, uploads another one with automatically (2) added, check + */ + @Test + fun testKeepBothStatic() { + val file = getDummyFile("nonEmpty.txt") + + FileUploadHelper().uploadNewFiles( + user, + arrayOf(file.absolutePath), + arrayOf("/testFile.txt"), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.DEFAULT + ) + + longSleep() + + val result = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result.isSuccess) + + assertEquals(file.length(), (result.data[0] as RemoteFile).length) + + val ocFile2 = OCFile("/testFile.txt") + ocFile2.storagePath = getDummyFile("empty.txt").absolutePath + + FileUploadHelper().uploadUpdatedFile( + user, + arrayOf(ocFile2), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + NameCollisionPolicy.RENAME + ) + + shortSleep() + + val result2 = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result2.isSuccess) + + assertEquals(file.length(), (result2.data[0] as RemoteFile).length) + + val result3 = ReadFileRemoteOperation("/testFile (2).txt").execute(client) + assertTrue(result3.isSuccess) + + assertEquals(ocFile2.fileLength, (result3.data[0] as RemoteFile).length) + } + + /** + * uploads a file with "keep server" option set, so do nothing + */ + @Test + fun testKeepServer() { + val file = getDummyFile("chunkedFile.txt") + val ocUpload = OCUpload(file.absolutePath, "/testFile.txt", account.name) + + assertTrue( + UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.DEFAULT, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + storageManager + ) + .setRemoteFolderToBeCreated() + .execute(client) + .isSuccess + ) + + val result = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result.isSuccess) + + assertEquals(file.length(), (result.data[0] as RemoteFile).length) + + val ocUpload2 = OCUpload(getDummyFile("empty.txt").absolutePath, "/testFile.txt", account.name) + + assertFalse( + UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload2, + NameCollisionPolicy.SKIP, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + storageManager + ) + .execute(client).isSuccess + ) + + val result2 = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result2.isSuccess) + + assertEquals(file.length(), (result2.data[0] as RemoteFile).length) + } + + /** + * uploads a file with "keep server" option set, so do nothing + */ + @Test + fun testKeepServerStatic() { + val file = getDummyFile("chunkedFile.txt") + + FileUploadHelper().uploadNewFiles( + user, + arrayOf(file.absolutePath), + arrayOf("/testFile.txt"), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.DEFAULT + ) + + longSleep() + + val result = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result.isSuccess) + + assertEquals(file.length(), (result.data[0] as RemoteFile).length) + + val ocFile2 = OCFile("/testFile.txt") + ocFile2.storagePath = getDummyFile("empty.txt").absolutePath + + FileUploadHelper().uploadUpdatedFile( + user, + arrayOf(ocFile2), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + NameCollisionPolicy.SKIP + ) + + shortSleep() + + val result2 = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result2.isSuccess) + + assertEquals(file.length(), (result2.data[0] as RemoteFile).length) + } + + /** + * uploads a file with "skip if exists" option set, so do nothing if file exists + */ + @Test + fun testCancelServer() { + val file = getDummyFile("chunkedFile.txt") + val ocUpload = OCUpload(file.absolutePath, "/testFile.txt", account.name) + + assertTrue( + UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.SKIP, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + storageManager + ) + .setRemoteFolderToBeCreated() + .execute(client) + .isSuccess + ) + + val result = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result.isSuccess) + + assertEquals(file.length(), (result.data[0] as RemoteFile).length) + + val ocUpload2 = OCUpload(getDummyFile("empty.txt").absolutePath, "/testFile.txt", account.name) + + val uploadResult = UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload2, + NameCollisionPolicy.SKIP, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + storageManager + ) + .execute(client) + + assertFalse(uploadResult.isSuccess) + assertTrue(uploadResult.exception is OperationCancelledException) + + val result2 = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result2.isSuccess) + + assertEquals(file.length(), (result2.data[0] as RemoteFile).length) + } + + /** + * uploads a file with "skip if exists" option set, so do nothing if file exists + */ + @Test + fun testKeepCancelStatic() { + val file = getDummyFile("chunkedFile.txt") + + FileUploadHelper().uploadNewFiles( + user, + arrayOf(file.absolutePath), + arrayOf("/testFile.txt"), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.DEFAULT + ) + + longSleep() + + val result = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result.isSuccess) + + assertEquals(file.length(), (result.data[0] as RemoteFile).length) + + val ocFile2 = OCFile("/testFile.txt") + ocFile2.storagePath = getDummyFile("empty.txt").absolutePath + + FileUploadHelper().uploadUpdatedFile( + user, + arrayOf(ocFile2), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + NameCollisionPolicy.SKIP + ) + + shortSleep() + + val result2 = ReadFileRemoteOperation("/testFile.txt").execute(client) + assertTrue(result2.isSuccess) + + assertEquals(file.length(), (result2.data[0] as RemoteFile).length) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/operations/GetSharesForFileOperationIT.kt b/app/src/androidTest/java/com/owncloud/android/operations/GetSharesForFileOperationIT.kt new file mode 100644 index 000000000000..1d268ce743db --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/operations/GetSharesForFileOperationIT.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations + +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.lib.resources.files.CreateFolderRemoteOperation +import com.owncloud.android.lib.resources.shares.CreateShareRemoteOperation +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType +import junit.framework.TestCase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@Suppress("MagicNumber") +class GetSharesForFileOperationIT : AbstractOnServerIT() { + @Test + fun shares() { + val remotePath = "/share/" + assertTrue(CreateFolderRemoteOperation(remotePath, true).execute(client).isSuccess) + + // share folder to user "admin" + TestCase.assertTrue( + CreateShareRemoteOperation( + remotePath, + ShareType.USER, + "admin", + false, + "", + OCShare.MAXIMUM_PERMISSIONS_FOR_FOLDER + ) + .execute(client).isSuccess + ) + + // share folder via public link + TestCase.assertTrue( + CreateShareRemoteOperation( + remotePath, + ShareType.PUBLIC_LINK, + "", + true, + "", + OCShare.READ_PERMISSION_FLAG + ) + .execute(client).isSuccess + ) + + // share folder to group + assertTrue( + CreateShareRemoteOperation( + remotePath, + ShareType.GROUP, + "users", + false, + "", + OCShare.NO_PERMISSION + ) + .execute(client).isSuccess + ) + + val shareResult = GetSharesForFileOperation(remotePath, false, false, storageManager).execute(client) + assertTrue(shareResult.isSuccess) + + assertEquals(3, (shareResult.data as ArrayList).size) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/operations/RemoveFileOperationIT.java b/app/src/androidTest/java/com/owncloud/android/operations/RemoveFileOperationIT.java new file mode 100644 index 000000000000..33f091059734 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/operations/RemoveFileOperationIT.java @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations; + +import com.owncloud.android.AbstractOnServerIT; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.db.OCUpload; + +import org.junit.Test; + +import java.io.IOException; + +import static junit.framework.TestCase.assertNotNull; +import static junit.framework.TestCase.assertTrue; + +public class RemoveFileOperationIT extends AbstractOnServerIT { + @Test + public void deleteFolder() { + String parent = "/test/"; + String path = parent + "folder1/"; + assertTrue(new CreateFolderOperation(path, user, targetContext, getStorageManager()).execute(client) + .isSuccess()); + + OCFile folder = getStorageManager().getFileByPath(path); + + assertNotNull(folder); + + assertTrue(new RemoveFileOperation(folder, + false, + user, + false, + targetContext, + getStorageManager()) + .execute(client) + .isSuccess()); + + OCFile parentFolder = getStorageManager().getFileByPath(parent); + + assertNotNull(parentFolder); + assertTrue(new RemoveFileOperation(parentFolder, + false, + user, + false, + targetContext, + getStorageManager()) + .execute(client) + .isSuccess()); + } + + @Test + public void deleteFile() throws IOException { + String parent = "/test/"; + String path = parent + "empty.txt"; + OCUpload ocUpload = new OCUpload(getDummyFile("empty.txt").getAbsolutePath(), path, account.name); + + uploadOCUpload(ocUpload); + + OCFile file = getStorageManager().getFileByPath(path); + + assertNotNull(file); + + assertTrue(new RemoveFileOperation(file, + false, + user, + false, + targetContext, + getStorageManager()) + .execute(client) + .isSuccess()); + + OCFile parentFolder = getStorageManager().getFileByPath(parent); + + assertNotNull(parentFolder); + assertTrue(new RemoveFileOperation(parentFolder, + false, + user, + false, + targetContext, + getStorageManager()) + .execute(client) + .isSuccess()); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/providers/DocumentsProviderUtils.kt b/app/src/androidTest/java/com/owncloud/android/providers/DocumentsProviderUtils.kt new file mode 100644 index 000000000000..cd5f69559114 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/providers/DocumentsProviderUtils.kt @@ -0,0 +1,206 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Torsten Grote + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.providers + +import android.content.Context +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID +import android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE +import android.provider.DocumentsContract.Document.MIME_TYPE_DIR +import android.provider.DocumentsContract.EXTRA_LOADING +import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree +import android.provider.DocumentsContract.buildDocumentUriUsingTree +import android.provider.DocumentsContract.buildTreeDocumentUri +import android.provider.DocumentsContract.getDocumentId +import androidx.annotation.VisibleForTesting +import androidx.documentfile.provider.DocumentFile +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation +import com.owncloud.android.providers.DocumentsStorageProvider.DOCUMENTID_SEPARATOR +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import java.io.IOException +import java.io.InputStream +import kotlin.coroutines.resume + +// Uploads can sometimes take a bit of time, so 15sec is still considered recent enough +private const val RECENT_MILLISECONDS = 15_000 + +object DocumentsProviderUtils { + + internal fun DocumentFile.getOCFile(storageManager: FileDataStorageManager): OCFile? { + val id = getDocumentId(uri) + val separated: List = id.split(DOCUMENTID_SEPARATOR.toRegex()) + return storageManager.getFileById(separated[1].toLong()) + } + + internal fun DocumentFile.assertRegularFile( + name: String? = null, + size: Long? = null, + mimeType: String? = null, + parent: DocumentFile? = null + ) { + name?.let { assertEquals(it, this.name) } + assertTrue(exists()) + assertTrue(isFile) + assertFalse(isDirectory) + assertFalse(isVirtual) + size?.let { assertEquals(it, length()) } + mimeType?.let { assertEquals(it, type) } + parent?.let { assertEquals(it.uri.toString(), parentFile!!.uri.toString()) } + } + + internal fun DocumentFile.assertRegularFolder(name: String? = null, parent: DocumentFile? = null) { + name?.let { assertEquals(it, this.name) } + assertTrue(exists()) + assertFalse(isFile) + assertTrue(isDirectory) + assertFalse(isVirtual) + parent?.let { assertEquals(it.uri.toString(), parentFile!!.uri.toString()) } + } + + internal fun DocumentFile.assertRecentlyModified() { + val diff = System.currentTimeMillis() - lastModified() + assertTrue("File $name older than expected: $diff", diff < RECENT_MILLISECONDS) + } + + internal fun assertExistsOnServer(client: OwnCloudClient, remotePath: String, shouldExist: Boolean) { + val result = ExistenceCheckRemoteOperation(remotePath, !shouldExist).execute(client) + assertTrue("$result", result.isSuccess) + } + + internal fun assertListFilesEquals(expected: Collection, actual: Collection) { +// assertEquals( +// "Actual: ${actual.map { it.name.toString() }}", +// expected.map { it.uri.toString() }.apply { sorted() }, +// actual.map { it.uri.toString() }.apply { sorted() }, +// ) + // FIXME replace with commented out stronger assertion above + // when parallel [UploadFileOperation]s don't bring back deleted files + val expectedSet = HashSet(expected.map { it.uri.toString() }) + val actualSet = HashSet(actual.map { it.uri.toString() }) + assertTrue(actualSet.containsAll(expectedSet)) + actualSet.removeAll(expectedSet) + actualSet.forEach { + Log_OC.e("TEST", "Error: Found unexpected file: $it") + } + } + + internal fun assertReadEquals(data: ByteArray, inputStream: InputStream?) { + assertNotNull(inputStream) + inputStream!!.use { + assertArrayEquals(data, it.readBytes()) + } + } + + /** + * Same as [DocumentFile.findFile] only that it re-queries when the first result was stale. + * + * Most documents providers including Nextcloud are listing the full directory content + * when querying for a specific file in a directory, + * so there is no point in trying to optimize the query by not listing all children. + */ + suspend fun DocumentFile.findFileBlocking(context: Context, displayName: String): DocumentFile? { + val files = listFilesBlocking(context) + for (doc in files) { + if (displayName == doc.name) return doc + } + return null + } + + /** + * Works like [DocumentFile.listFiles] except that it waits until the DocumentProvider has a result. + * This prevents getting an empty list even though there are children to be listed. + */ + suspend fun DocumentFile.listFilesBlocking(context: Context) = withContext(Dispatchers.IO) { + val resolver = context.contentResolver + val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri)) + val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE) + val result = ArrayList() + + try { + getLoadedCursor { + resolver.query(childrenUri, projection, null, null, null) + } + } catch (e: TimeoutCancellationException) { + throw IOException(e) + }.use { cursor -> + while (cursor.moveToNext()) { + val documentId = cursor.getString(0) + val isDirectory = cursor.getString(1) == MIME_TYPE_DIR + val file = if (isDirectory) { + val treeUri = buildTreeDocumentUri(uri.authority, documentId) + DocumentFile.fromTreeUri(context, treeUri)!! + } else { + val documentUri = buildDocumentUriUsingTree(uri, documentId) + DocumentFile.fromSingleUri(context, documentUri)!! + } + result.add(file) + } + } + result + } + + /** + * Returns a cursor for the given query while ensuring that the cursor was loaded. + * + * When the SAF backend is a cloud storage provider (e.g. Nextcloud), + * it can happen that the query returns an outdated (e.g. empty) cursor + * which will only be updated in response to this query. + * + * See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html + * + * This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver] + * registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]). + * If the cursor is not loading, it will be returned right away. + * + * @param timeout an optional time-out in milliseconds + * @throws TimeoutCancellationException if there was no result before the time-out + * @throws IOException if the query returns null + */ + @Suppress("EXPERIMENTAL_API_USAGE") + @VisibleForTesting + internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) = withTimeout(timeout) { + suspendCancellableCoroutine { cont -> + val cursor = query() ?: throw IOException("Initial query returned no results") + cont.invokeOnCancellation { cursor.close() } + val loading = cursor.extras?.getBoolean(EXTRA_LOADING, false) ?: false + if (loading) { + Log_OC.e("TEST", "Cursor was loading, wait for update...") + cursor.registerContentObserver( + object : ContentObserver(null) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + cursor.close() + val newCursor = query() + if (newCursor == null) { + cont.cancel(IOException("Re-query returned no results")) + } else { + cont.resume(newCursor) + } + } + } + ) + } else { + // not loading, return cursor right away + cont.resume(cursor) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/providers/DocumentsStorageProviderIT.kt b/app/src/androidTest/java/com/owncloud/android/providers/DocumentsStorageProviderIT.kt new file mode 100644 index 000000000000..3bdd0e010152 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/providers/DocumentsStorageProviderIT.kt @@ -0,0 +1,269 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Torsten Grote + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.providers + +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import com.nextcloud.test.RandomStringGenerator +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile.ROOT_PATH +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.providers.DocumentsProviderUtils.assertExistsOnServer +import com.owncloud.android.providers.DocumentsProviderUtils.assertListFilesEquals +import com.owncloud.android.providers.DocumentsProviderUtils.assertReadEquals +import com.owncloud.android.providers.DocumentsProviderUtils.assertRecentlyModified +import com.owncloud.android.providers.DocumentsProviderUtils.assertRegularFile +import com.owncloud.android.providers.DocumentsProviderUtils.assertRegularFolder +import com.owncloud.android.providers.DocumentsProviderUtils.findFileBlocking +import com.owncloud.android.providers.DocumentsProviderUtils.getOCFile +import com.owncloud.android.providers.DocumentsProviderUtils.listFilesBlocking +import com.owncloud.android.providers.DocumentsStorageProvider.DOCUMENTID_SEPARATOR +import kotlinx.coroutines.runBlocking +import org.apache.commons.httpclient.HttpStatus +import org.apache.commons.httpclient.methods.ByteArrayRequestEntity +import org.apache.jackrabbit.webdav.client.methods.PutMethod +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.random.Random + +private const val MAX_FILE_NAME_LENGTH = 225 + +class DocumentsStorageProviderIT : AbstractOnServerIT() { + + private val context = targetContext + private val contentResolver = context.contentResolver + private val authority = context.getString(R.string.document_provider_authority) + + private val rootFileId = storageManager.getFileByEncryptedRemotePath(ROOT_PATH).fileId + private val documentId = "${DocumentsStorageProvider.rootIdForUser(user)}${DOCUMENTID_SEPARATOR}$rootFileId" + private val uri = DocumentsContract.buildTreeDocumentUri(authority, documentId) + private val rootDir get() = DocumentFile.fromTreeUri(context, uri)!! + + @Before + fun before() { + // DocumentsProvider#onCreate() is called when the application is started + // which is *after* AbstractOnServerIT adds the accounts (when the app is freshly installed). + // So we need to query our roots here to ensure that the internal storage map is initialized. + storageManager.run { + val updatedRootPath = getFileByEncryptedRemotePath(ROOT_PATH).apply { + permissions = "RSMCKGWDNV" + } + + saveFile(updatedRootPath) + } + + contentResolver.query(DocumentsContract.buildRootsUri(authority), null, null, null) + assertTrue("Storage root does not exist", rootDir.exists()) + assertTrue(rootDir.isDirectory) + } + + /** + * Delete all files in [rootDir] after each test. + * + * We can't use [AbstractOnServerIT.after] as this is only deleting remote files. + */ + @After + override fun after() = runBlocking { + rootDir.listFilesBlocking(context).forEach { + Log_OC.e("TEST", "Deleting ${it.name}...") + it.delete() + } + } + + @Test + fun testCreateDeleteFiles() = runBlocking { + // no files in root initially + assertListFilesEquals(emptyList(), rootDir.listFilesBlocking(context)) + + // create first file + val name1 = RandomStringGenerator.make() + val type1 = "text/html" + val file1 = rootDir.createFile(type1, name1)!! + + // check assumptions + /* FIXME: mimeType */ + file1.assertRegularFile(name1, 0L, null, rootDir) + file1.assertRecentlyModified() + + // file1 is found in root + assertListFilesEquals(listOf(file1), rootDir.listFilesBlocking(context).toList()) + + // file1 was uploaded + val ocFile1 = file1.getOCFile(storageManager)!! + assertExistsOnServer(client, ocFile1.remotePath, true) + + // create second long file with long file name + val name2 = RandomStringGenerator.make(MAX_FILE_NAME_LENGTH) + val type2 = "application/octet-stream" + val file2 = rootDir.createFile(type2, name2)!! + + // file2 was uploaded + val ocFile2 = file2.getOCFile(storageManager)!! + assertExistsOnServer(client, ocFile2.remotePath, true) + + // check assumptions + file2.assertRegularFile(name2, 0L, type2, rootDir) + file2.assertRecentlyModified() + + // both files get listed in root + assertListFilesEquals(listOf(file1, file2), rootDir.listFiles().toList()) + + // delete first file + assertTrue(file1.delete()) + assertFalse(file1.exists()) + assertExistsOnServer(client, ocFile1.remotePath, false) + + // only second file gets listed in root + assertListFilesEquals(listOf(file2), rootDir.listFiles().toList()) + + // delete also second file + assertTrue(file2.delete()) + assertFalse(file2.exists()) + assertExistsOnServer(client, ocFile2.remotePath, false) + + // no more files in root + assertListFilesEquals(emptyList(), rootDir.listFilesBlocking(context)) + } + + @Test + fun testReadWriteFiles() { + // create random file + val file1 = rootDir.createFile("application/octet-stream", RandomStringGenerator.make())!! + file1.assertRegularFile(size = 0L) + + // write random bytes to file + @Suppress("MagicNumber") + val dataSize = Random.nextInt(1, 99) * 1024 + val data1 = Random.nextBytes(dataSize) + contentResolver.openOutputStream(file1.uri, "wt").use { + it!!.write(data1) + } + + // read back random bytes + assertReadEquals(data1, contentResolver.openInputStream(file1.uri)) + + // file size was updated correctly + file1.assertRegularFile(size = data1.size.toLong()) + } + + @Test + fun testCreateDeleteFolders() = runBlocking { + // create a new folder + val dirName1 = RandomStringGenerator.make() + val dir1 = rootDir.createDirectory(dirName1)!! + dir1.assertRegularFolder(dirName1, rootDir) + // FIXME about a minute gets lost somewhere after CFO sets the correct time + @Suppress("MagicNumber") + assertTrue(System.currentTimeMillis() - dir1.lastModified() < 60_000) +// dir1.assertRecentlyModified() + + // ensure folder was uploaded to server + val ocDir1 = dir1.getOCFile(storageManager)!! + assertExistsOnServer(client, ocDir1.remotePath, true) + + // create file in folder + val file1 = dir1.createFile("text/html", RandomStringGenerator.make())!! + file1.assertRegularFile(parent = dir1) + val ocFile1 = file1.getOCFile(storageManager)!! + assertExistsOnServer(client, ocFile1.remotePath, true) + + // we find the new file in the created folder and get it in the list + assertEquals(file1.uri.toString(), dir1.findFileBlocking(context, file1.name!!)!!.uri.toString()) + assertListFilesEquals(listOf(file1), dir1.listFilesBlocking(context)) + + // delete folder + dir1.delete() + assertFalse(dir1.exists()) + assertExistsOnServer(client, ocDir1.remotePath, false) + + // ensure file got deleted with it + // since Room was introduced, the file is not automatically updated for some reason. + // however, it is correctly deleted from server, and smoke testing shows it works just fine. + // suspecting a race condition of some sort + // assertFalse(file1.exists()) + assertExistsOnServer(client, ocFile1.remotePath, false) + } + + @Suppress("MagicNumber") + @Test(timeout = 5 * 60 * 1000) + fun testServerChangedFileContent() { + // create random file + val file1 = rootDir.createFile("text/plain", RandomStringGenerator.make())!! + file1.assertRegularFile(size = 0L) + + val createdETag = file1.getOCFile(storageManager)!!.etagOnServer + + assertTrue(createdETag.isNotEmpty()) + + val content1 = "initial content".toByteArray() + + // write content bytes to file + contentResolver.openOutputStream(file1.uri, "wt").use { + it!!.write(content1) + } + + // refresh + while (file1.getOCFile(storageManager)!!.etagOnServer == createdETag) { + shortSleep() + rootDir.listFiles() + } + + val remotePath = file1.getOCFile(storageManager)!!.remotePath + + val content2 = "new content".toByteArray() + + // modify content on server side + val putMethod = PutMethod(client.getFilesDavUri(remotePath)) + putMethod.requestEntity = ByteArrayRequestEntity(content2) + assertEquals(HttpStatus.SC_NO_CONTENT, client.executeMethod(putMethod)) + client.exhaustResponse(putMethod.responseBodyAsStream) + putMethod.releaseConnection() // let the connection available for other methods + + // read back content bytes + val bytes = contentResolver.openInputStream(file1.uri)?.readBytes() ?: ByteArray(0) + assertEquals(String(content2), String(bytes)) + } + + @Test + fun testServerSuccessive() { + // create random file + val file1 = rootDir.createFile("text/plain", RandomStringGenerator.make())!! + file1.assertRegularFile(size = 0L) + + val createdETag = file1.getOCFile(storageManager)!!.etagOnServer + + assertTrue(createdETag.isNotEmpty()) + + val content1 = "initial content".toByteArray() + + // write content bytes to file + contentResolver.openOutputStream(file1.uri, "wt").use { + it!!.write(content1) + } + + // refresh + while (file1.getOCFile(storageManager)!!.etagOnServer == createdETag) { + shortSleep() + rootDir.listFiles() + } + + val content2 = "new content".toByteArray() + + contentResolver.openOutputStream(file1.uri, "wt").use { + it!!.write(content2) + } + + // read back content bytes + val bytes = contentResolver.openInputStream(file1.uri)?.readBytes() ?: ByteArray(0) + assertEquals(String(content2), String(bytes)) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/providers/FileContentProviderTests.kt b/app/src/androidTest/java/com/owncloud/android/providers/FileContentProviderTests.kt new file mode 100644 index 000000000000..8027feb9bd08 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/providers/FileContentProviderTests.kt @@ -0,0 +1,158 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.providers + +import android.content.ContentValues +import android.content.ContentUris +import android.net.Uri +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteDatabase +import com.owncloud.android.db.ProviderMeta +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@Suppress("MagicNumber") +class FileContentProviderTests { + + private lateinit var provider: FileContentProvider + private lateinit var db: SupportSQLiteDatabase + + @Before + fun setup() { + provider = FileContentProvider() + db = mockk() + } + + @Test + fun insertNewFileShouldReturnNewId() { + val values = ContentValues().apply { + put(ProviderMeta.ProviderTableMeta.FILE_PATH, "/path/to/file.txt") + put(ProviderMeta.ProviderTableMeta.FILE_ACCOUNT_OWNER, "user@example.com") + } + + // Mock insert to return new ID + every { db.insert(any(), any(), any()) } returns 42L + + val result: Uri = provider.upsertSingleFile( + db, + ProviderMeta.ProviderTableMeta.CONTENT_URI_FILE, + values + ) + + assertEquals( + ContentUris.withAppendedId(ProviderMeta.ProviderTableMeta.CONTENT_URI_FILE, 42), + result + ) + } + + @Test + fun updateExistingFileShouldReturnSameId() { + val values = ContentValues().apply { + put(ProviderMeta.ProviderTableMeta.FILE_PATH, "/path/to/file.txt") + put(ProviderMeta.ProviderTableMeta.FILE_ACCOUNT_OWNER, "user@example.com") + } + + // Simulate insert conflict + every { db.insert(ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME, any(), values) } returns -1L + + // Simulate update returning 1 row affected + every { + db.update( + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME, + any(), + values, + any(), + any() + ) + } returns 1 + + // Mock cursor to return ID 99 + val cursor = mockk() + every { cursor.moveToFirst() } returns true + every { cursor.getLong(0) } returns 99L + every { cursor.close() } just Runs + + every { db.query(any()) } returns cursor + + val result: Uri = provider.upsertSingleFile( + db, + ProviderMeta.ProviderTableMeta.CONTENT_URI_FILE, + values + ) + + assertEquals(ContentUris.withAppendedId(ProviderMeta.ProviderTableMeta.CONTENT_URI_FILE, 99), result) + + cursor.close() + } + + @Test + fun testConcurrentUpserts() = runBlocking { + val values = ContentValues().apply { + put(ProviderMeta.ProviderTableMeta.FILE_PATH, "/path/to/file.txt") + put(ProviderMeta.ProviderTableMeta.FILE_ACCOUNT_OWNER, "user@example.com") + } + + // shared state to simulate race + val inserted = mutableListOf() + + // mock insert: fail first call, succeed second + every { db.insert(any(), any(), any()) } answers { + synchronized(inserted) { + if (inserted.isEmpty()) { + // first thread "fails" insert which means already existing file id will be returned + inserted.add(-1L) + -1L + } else { + // second thread "succeeds" it will update existing one + inserted.add(42L) + 42L + } + } + } + + // mock update only one row should be affected + every { db.update(any(), any(), any(), any(), any()) } returns 1 + + // mock query existing file id will return 99 + val cursor = mockk() + every { cursor.moveToFirst() } returns true + every { cursor.getLong(0) } returns 99L + every { cursor.close() } just Runs + every { db.query(any()) } returns cursor + + // launch two coroutines simulating concurrent threads + val results = mutableListOf() + coroutineScope { + val job1 = + async { + results.add(provider.upsertSingleFile(db, ProviderMeta.ProviderTableMeta.CONTENT_URI_FILE, values)) + } + val job2 = + async { + results.add(provider.upsertSingleFile(db, ProviderMeta.ProviderTableMeta.CONTENT_URI_FILE, values)) + } + awaitAll(job1, job2) + } + + // both URIs should be correct (one updated, one inserted) + assertTrue(results.contains(ContentUris.withAppendedId(ProviderMeta.ProviderTableMeta.CONTENT_URI_FILE, 99))) + assertTrue(results.contains(ContentUris.withAppendedId(ProviderMeta.ProviderTableMeta.CONTENT_URI_FILE, 42))) + + cursor.close() + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/providers/FileContentProviderVerificationIT.kt b/app/src/androidTest/java/com/owncloud/android/providers/FileContentProviderVerificationIT.kt new file mode 100644 index 000000000000..573f32031efb --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/providers/FileContentProviderVerificationIT.kt @@ -0,0 +1,100 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.providers + +import android.content.ContentValues +import com.owncloud.android.db.ProviderMeta +import com.owncloud.android.utils.MimeTypeUtil +import org.junit.Test + +@Suppress("FunctionNaming") +class FileContentProviderVerificationIT { + + companion object { + private const val INVALID_COLUMN = "Invalid column" + private const val FILE_LENGTH = 120 + } + + @Test(expected = IllegalArgumentException::class) + fun verifyColumnName_Exception() { + FileContentProvider.VerificationUtils.verifyColumnName(INVALID_COLUMN) + } + + @Test + fun verifyColumnName_OK() { + FileContentProvider.VerificationUtils.verifyColumnName(ProviderMeta.ProviderTableMeta.FILE_NAME) + } + + @Test + fun verifyColumn_ContentValues_OK() { + // with valid columns + val contentValues = ContentValues() + contentValues.put(ProviderMeta.ProviderTableMeta.FILE_CONTENT_LENGTH, FILE_LENGTH) + contentValues.put(ProviderMeta.ProviderTableMeta.FILE_CONTENT_TYPE, MimeTypeUtil.MIMETYPE_TEXT_MARKDOWN) + FileContentProvider.VerificationUtils.verifyColumns(contentValues) + + // empty + FileContentProvider.VerificationUtils.verifyColumns(ContentValues()) + } + + @Test(expected = IllegalArgumentException::class) + fun verifyColumn_ContentValues_invalidColumn() { + // with invalid columns + val contentValues = ContentValues() + contentValues.put(INVALID_COLUMN, FILE_LENGTH) + contentValues.put(ProviderMeta.ProviderTableMeta.FILE_CONTENT_TYPE, MimeTypeUtil.MIMETYPE_TEXT_MARKDOWN) + FileContentProvider.VerificationUtils.verifyColumns(contentValues) + } + + @Test + fun verifySortOrder_OK() { + // null + FileContentProvider.VerificationUtils.verifySortOrder(null) + + // empty + FileContentProvider.VerificationUtils.verifySortOrder("") + + // valid sort + FileContentProvider.VerificationUtils.verifySortOrder(ProviderMeta.ProviderTableMeta.FILE_DEFAULT_SORT_ORDER) + } + + @Test(expected = IllegalArgumentException::class) + fun verifySortOrder_InvalidColumn() { + // with invalid column + FileContentProvider.VerificationUtils.verifySortOrder("$INVALID_COLUMN desc") + } + + @Test(expected = IllegalArgumentException::class) + fun verifySortOrder_InvalidGrammar() { + // with invalid grammar + FileContentProvider.VerificationUtils.verifySortOrder("${ProviderMeta.ProviderTableMeta._ID} ;--foo") + } + + @Test + fun verifyWhere_OK() { + FileContentProvider.VerificationUtils.verifyWhere(null) + FileContentProvider.VerificationUtils.verifyWhere( + "${ProviderMeta.ProviderTableMeta._ID}=? AND ${ProviderMeta.ProviderTableMeta.FILE_ACCOUNT_OWNER}=?" + ) + FileContentProvider.VerificationUtils.verifyWhere( + "${ProviderMeta.ProviderTableMeta._ID} = 1" + + " AND (1 = 1)" + + " AND ${ProviderMeta.ProviderTableMeta.FILE_ACCOUNT_OWNER} LIKE ?" + ) + } + + @Test(expected = IllegalArgumentException::class) + fun verifyWhere_InvalidColumnName() { + FileContentProvider.VerificationUtils.verifyWhere("$INVALID_COLUMN= ?") + } + + @Test(expected = IllegalArgumentException::class) + fun verifyWhere_InvalidGrammar() { + FileContentProvider.VerificationUtils.verifyWhere("1=1 -- SELECT * FROM") + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/providers/UsersAndGroupsSearchProviderIT.kt b/app/src/androidTest/java/com/owncloud/android/providers/UsersAndGroupsSearchProviderIT.kt new file mode 100644 index 000000000000..c6cb983b09d5 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/providers/UsersAndGroupsSearchProviderIT.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.providers + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.TestActivity +import org.junit.Test + +class UsersAndGroupsSearchProviderIT { + @Test + fun searchUser() { + launchActivity().use { + onView(isRoot()).check(matches(isDisplayed())) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/LoginIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/LoginIT.kt new file mode 100644 index 000000000000..9601da95584c --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/LoginIT.kt @@ -0,0 +1,135 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui + +import android.os.Build +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.web.sugar.Web +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.Locator +import androidx.test.filters.LargeTest +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.test.GrantStoragePermissionRule +import com.nextcloud.test.RetryTestRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.authentication.AuthenticatorActivity +import org.junit.AfterClass +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@LargeTest +class LoginIT : AbstractIT() { + @get:Rule + val permissionRule = GrantStoragePermissionRule.grant() + + @get:Rule + var retryTestRule = RetryTestRule() + + @Before + fun setUp() { + tearDown() + ActivityScenario.launch(AuthenticatorActivity::class.java) + } + + /** + * The CI/CD pipeline is encountering issues related to the Android version for this functionality. + * Therefore the test will only be executed on Android versions 10 and above. + */ + @Test + @Throws(InterruptedException::class) + @Suppress("MagicNumber", "SwallowedException") + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q) + fun login() { + val arguments = InstrumentationRegistry.getArguments() + val baseUrl = arguments.getString("TEST_SERVER_URL")!! + val loginName = arguments.getString("TEST_SERVER_USERNAME")!! + val password = arguments.getString("TEST_SERVER_PASSWORD")!! + Espresso.onView(ViewMatchers.withId(R.id.login)).perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.host_url_input)).perform(ViewActions.typeText(baseUrl)) + Espresso.onView(ViewMatchers.withId(R.id.host_url_input)).perform(ViewActions.typeTextIntoFocusedView("\n")) + Thread.sleep(3000) + Web.onWebView().forceJavascriptEnabled() + + // click on login + try { + // NC 25+ + Web.onWebView() + .withElement(DriverAtoms.findElement(Locator.XPATH, "//form[@id='login-form']/input[@type='submit']")) + .perform(DriverAtoms.webClick()) + } catch (_: RuntimeException) { + // NC < 25 + Web.onWebView() + .withElement(DriverAtoms.findElement(Locator.XPATH, "//p[@id='redirect-link']/a")) + .perform(DriverAtoms.webClick()) + } + + // username + Web.onWebView() + .withElement(DriverAtoms.findElement(Locator.XPATH, "//input[@id='user']")) + .perform(DriverAtoms.webKeys(loginName)) + + // password + Web.onWebView() + .withElement(DriverAtoms.findElement(Locator.XPATH, "//input[@id='password']")) + .perform(DriverAtoms.webKeys(password)) + + // click login + try { + // NC 25+ + Web.onWebView() + .withElement(DriverAtoms.findElement(Locator.XPATH, "//button[@type='submit']")) + .perform(DriverAtoms.webClick()) + } catch (_: RuntimeException) { + // NC < 25 + Web.onWebView() + .withElement(DriverAtoms.findElement(Locator.XPATH, "//input[@type='submit']")) + .perform(DriverAtoms.webClick()) + } + + Thread.sleep(2000) + + // grant access + Web.onWebView() + .withElement(DriverAtoms.findElement(Locator.XPATH, "//input[@type='submit']")) + .perform(DriverAtoms.webClick()) + Thread.sleep((5 * 1000).toLong()) + + // check for account + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val accountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext) + Assert.assertEquals(1, accountManager.accounts.size.toLong()) + val account = accountManager.accounts[0] + + // account.name is loginName@baseUrl (without protocol) + Assert.assertEquals(loginName, account.name.split("@".toRegex()).toTypedArray()[0]) + Assert.assertEquals( + baseUrl.split("//".toRegex()).toTypedArray()[1], + account.name.split("@".toRegex()).toTypedArray()[1] + ) + } + + companion object { + @AfterClass + fun tearDown() { + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val accountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext) + if (accountManager.accounts.isNotEmpty()) { + accountManager.removeAllAccounts() + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/ConflictsResolveActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/ConflictsResolveActivityIT.kt new file mode 100644 index 000000000000..a115f79b1308 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/ConflictsResolveActivityIT.kt @@ -0,0 +1,310 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import androidx.fragment.app.DialogFragment +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.utils.extensions.getDecryptedPath +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload +import com.owncloud.android.ui.dialog.ConflictsResolveDialog +import com.owncloud.android.ui.dialog.ConflictsResolveDialog.Companion.newInstance +import com.owncloud.android.ui.dialog.ConflictsResolveDialog.Decision +import com.owncloud.android.ui.dialog.ConflictsResolveDialog.OnConflictDecisionMadeListener +import com.owncloud.android.utils.EspressoIdlingResource +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.ScreenshotTest +import junit.framework.TestCase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConflictsResolveActivityIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.activity.ConflictsResolveActivityIT" + private var returnCode = false + + @Test + @ScreenshotTest + fun screenshotTextFiles() { + val newFile = OCFile("/newFile.txt").apply { + remoteId = "0001" + fileLength = 56000 + modificationTimestamp = 1522019340 + setStoragePath(FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt") + } + + val existingFile = OCFile("/newFile.txt").apply { + remoteId = "0002" + fileLength = 1024000 + modificationTimestamp = 1582019340 + } + + val storageManager = FileDataStorageManager(user, targetContext.contentResolver) + storageManager.saveNewFile(existingFile) + + val intent = Intent(targetContext, ConflictsResolveActivity::class.java).apply { + putExtra(FileActivity.EXTRA_FILE, newFile) + putExtra(ConflictsResolveActivity.EXTRA_EXISTING_FILE, existingFile) + } + + launchActivity(intent).use { scenario -> + var dialog: ConflictsResolveDialog? = null + scenario.onActivity { sut -> + dialog = newInstance( + storageManager.getDecryptedPath(existingFile), + targetContext, + newFile, + existingFile, + UserAccountManagerImpl + .fromContext(targetContext) + .getUser() + ) + dialog.showDialog(sut) + } + + onView(withId(R.id.headline)) + .check(matches(isDisplayed())) + + val screenShotName = createName(testClassName + "_" + "screenshotTextFiles", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(dialog!!.requireDialog().window?.decorView, screenShotName) + } + } + } + + @Test + fun cancel() { + val newUpload = OCUpload( + FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt", + "/newFile.txt", + user.accountName + ) + + val existingFile = OCFile("/newFile.txt").apply { + fileLength = 1024000 + modificationTimestamp = 1582019340 + } + + val newFile = OCFile("/newFile.txt").apply { + fileLength = 56000 + modificationTimestamp = 1522019340 + setStoragePath(FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt") + } + + EspressoIdlingResource.increment() + FileDataStorageManager(user, targetContext.contentResolver).run { + saveNewFile(existingFile) + } + EspressoIdlingResource.decrement() + + val intent = Intent(targetContext, ConflictsResolveActivity::class.java).apply { + putExtra(FileActivity.EXTRA_FILE, newFile) + putExtra(ConflictsResolveActivity.EXTRA_EXISTING_FILE, existingFile) + putExtra(ConflictsResolveActivity.EXTRA_CONFLICT_UPLOAD_ID, newUpload.uploadId) + } + + launchActivity(intent).use { scenario -> + scenario.onActivity { sut -> + returnCode = false + sut.listener = OnConflictDecisionMadeListener { decision: Decision? -> + assertEquals(decision, Decision.CANCEL) + returnCode = true + } + } + + onView(ViewMatchers.withText("Cancel")).perform(ViewActions.click()) + TestCase.assertTrue(returnCode) + } + } + + @Test + @ScreenshotTest + fun keepExisting() { + returnCode = false + + val newUpload = OCUpload( + FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt", + "/newFile.txt", + user.accountName + ) + + val existingFile = OCFile("/newFile.txt").apply { + remoteId = "0001" + fileLength = 1024000 + modificationTimestamp = 1582019340 + } + + val newFile = OCFile("/newFile.txt").apply { + fileLength = 56000 + remoteId = "0002" + modificationTimestamp = 1522019340 + setStoragePath(FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt") + } + + EspressoIdlingResource.increment() + FileDataStorageManager(user, targetContext.contentResolver).run { + saveNewFile(existingFile) + } + EspressoIdlingResource.decrement() + + val intent = Intent(targetContext, ConflictsResolveActivity::class.java).apply { + putExtra(FileActivity.EXTRA_FILE, newFile) + putExtra(ConflictsResolveActivity.EXTRA_EXISTING_FILE, existingFile) + putExtra(ConflictsResolveActivity.EXTRA_CONFLICT_UPLOAD_ID, newUpload.uploadId) + } + + launchActivity(intent).use { scenario -> + var activity: ConflictsResolveActivity? = null + scenario.onActivity { sut -> + activity = sut + sut.listener = OnConflictDecisionMadeListener { decision: Decision? -> + assertEquals(decision, Decision.KEEP_SERVER) + returnCode = true + } + } + + onView(withId(R.id.right_checkbox)).perform(ViewActions.click()) + + val dialog = activity!!.supportFragmentManager.findFragmentByTag("conflictDialog") as DialogFragment? + val screenShotName = createName(testClassName + "_" + "keepExisting", "") + + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(dialog?.requireDialog()?.window?.decorView, screenShotName) + + onView(ViewMatchers.withText("OK")).perform(ViewActions.click()) + assertTrue(returnCode) + } + } + + @Test + @ScreenshotTest + fun keepNew() { + returnCode = false + + val newUpload = OCUpload( + FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt", + "/newFile.txt", + user.accountName + ) + + val existingFile = OCFile("/newFile.txt").apply { + fileLength = 1024000 + modificationTimestamp = 1582019340 + remoteId = "00000123abc" + } + + val newFile = OCFile("/newFile.txt").apply { + fileLength = 56000 + modificationTimestamp = 1522019340 + setStoragePath(FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt") + } + + val storageManager = FileDataStorageManager(user, targetContext.contentResolver) + storageManager.saveNewFile(existingFile) + + val intent = Intent(targetContext, ConflictsResolveActivity::class.java) + intent.putExtra(FileActivity.EXTRA_FILE, newFile) + intent.putExtra(ConflictsResolveActivity.EXTRA_EXISTING_FILE, existingFile) + intent.putExtra(ConflictsResolveActivity.EXTRA_CONFLICT_UPLOAD_ID, newUpload.uploadId) + + launchActivity(intent).use { scenario -> + var activity: ConflictsResolveActivity? = null + scenario.onActivity { sut -> + activity = sut + sut.listener = OnConflictDecisionMadeListener { decision: Decision? -> + assertEquals(decision, Decision.KEEP_LOCAL) + returnCode = true + } + } + + onView(withId(R.id.left_checkbox)).perform(ViewActions.click()) + val dialog = activity!!.supportFragmentManager.findFragmentByTag("conflictDialog") as DialogFragment? + val screenShotName = createName(testClassName + "_" + "keepNew", "") + screenshotViaName(dialog?.requireDialog()?.window?.decorView, screenShotName) + + onView(ViewMatchers.withText("OK")).perform(ViewActions.click()) + assertTrue(returnCode) + } + } + + @Test + @ScreenshotTest + fun keepBoth() { + returnCode = false + + val newUpload = OCUpload( + FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt", + "/newFile.txt", + user.accountName + ) + + val existingFile = OCFile("/newFile.txt").apply { + remoteId = "0001" + fileLength = 1024000 + modificationTimestamp = 1582019340 + } + + val newFile = OCFile("/newFile.txt").apply { + fileLength = 56000 + remoteId = "0002" + modificationTimestamp = 1522019340 + setStoragePath(FileStorageUtils.getSavePath(user.accountName) + "/nonEmpty.txt") + } + + val storageManager = FileDataStorageManager(user, targetContext.contentResolver) + storageManager.saveNewFile(existingFile) + + val intent = Intent(targetContext, ConflictsResolveActivity::class.java).apply { + putExtra(FileActivity.EXTRA_FILE, newFile) + putExtra(ConflictsResolveActivity.EXTRA_EXISTING_FILE, existingFile) + putExtra(ConflictsResolveActivity.EXTRA_CONFLICT_UPLOAD_ID, newUpload.uploadId) + } + + launchActivity(intent).use { scenario -> + var activity: ConflictsResolveActivity? = null + scenario.onActivity { sut -> + activity = sut + sut.listener = OnConflictDecisionMadeListener { decision: Decision? -> + assertEquals(decision, Decision.KEEP_BOTH) + returnCode = true + } + } + + onView(withId(R.id.right_checkbox)).perform(ViewActions.click()) + onView(withId(R.id.left_checkbox)).perform(ViewActions.click()) + + val dialog = activity!!.supportFragmentManager.findFragmentByTag("conflictDialog") as DialogFragment? + val screenShotName = createName(testClassName + "_" + "keepBoth", "") + screenshotViaName(dialog?.requireDialog()?.window?.decorView, screenShotName) + + onView(ViewMatchers.withText("OK")).perform(ViewActions.click()) + assertTrue(returnCode) + } + } + + @After + override fun after() { + storageManager.deleteAllFiles() + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/ContactsPreferenceActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/ContactsPreferenceActivityIT.kt new file mode 100644 index 000000000000..1ac5922cd329 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/ContactsPreferenceActivityIT.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContactsPreferenceActivityIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.activity.ContactsPreferenceActivityIT" + + @Test + @ScreenshotTest + fun openVCF() { + val file = getFile("vcard.vcf") + val vcfFile = OCFile("/contacts.vcf") + vcfFile.storagePath = file.absolutePath + + assertTrue(vcfFile.isDown) + + val intent = Intent(targetContext, ContactsPreferenceActivity::class.java).apply { + putExtra(ContactsPreferenceActivity.EXTRA_FILE, vcfFile) + putExtra(ContactsPreferenceActivity.EXTRA_USER, user) + } + + launchActivity(intent).use { scenario -> + val screenShotName = createName(testClassName + "_" + "openVCF", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun openContactsPreference() { + launchActivity().use { scenario -> + val screenShotName = createName(testClassName + "_" + "openContactsPreference", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/DrawerActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/DrawerActivityIT.kt new file mode 100644 index 000000000000..93caa0dba447 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/DrawerActivityIT.kt @@ -0,0 +1,131 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.net.Uri +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.test.RetryTestRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.lib.common.accounts.AccountUtils +import org.hamcrest.Matchers +import org.junit.Assert +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import java.util.function.Supplier + +class DrawerActivityIT : AbstractIT() { + @Rule + @JvmField + val retryTestRule = RetryTestRule() + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.POST_NOTIFICATIONS + ) + + @Test + fun switchAccountViaAccountList() { + launchActivity().use { scenario -> + var sut: FileDisplayActivity? = null + scenario.onActivity { activity -> + sut = activity + } + + Assert.assertEquals(account1, sut!!.user.get().toPlatformAccount()) + + onView(ViewMatchers.withId(R.id.switch_account_button)).perform(ViewActions.click()) + onView( + Matchers.anyOf( + ViewMatchers.withText(account2Name), + ViewMatchers.withText( + account2DisplayName + ) + ) + ).perform(ViewActions.click()) + + Assert.assertEquals(account2, sut.user.get().toPlatformAccount()) + + onView(ViewMatchers.withId(R.id.switch_account_button)).perform(ViewActions.click()) + onView(ViewMatchers.withText(account1?.name)).perform(ViewActions.click()) + } + } + + companion object { + private var account1: Account? = null + private var user1: User? = null + private var account2: Account? = null + private var account2Name: String? = null + private var account2DisplayName: String? = null + + @JvmStatic + @BeforeClass + fun beforeClass() { + val arguments = InstrumentationRegistry.getArguments() + val baseUrl = Uri.parse(arguments.getString("TEST_SERVER_URL")) + + val platformAccountManager = AccountManager.get(targetContext) + val userAccountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext) + + for (account in platformAccountManager.accounts) { + platformAccountManager.removeAccountExplicitly(account) + } + + var loginName = "user1" + var password = "user1" + + var temp = Account("$loginName@$baseUrl", MainApp.getAccountType(targetContext)) + platformAccountManager.addAccountExplicitly(temp, password, null) + platformAccountManager.setUserData( + temp, + AccountUtils.Constants.KEY_OC_ACCOUNT_VERSION, + UserAccountManager.ACCOUNT_VERSION.toString() + ) + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_VERSION, "14.0.0.0") + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_BASE_URL, baseUrl.toString()) + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_USER_ID, loginName) // same as userId + + account1 = userAccountManager.getAccountByName("$loginName@$baseUrl") + user1 = userAccountManager.getUser(account1!!.name) + .orElseThrow(Supplier { IllegalAccessError() }) + + loginName = "user2" + password = "user2" + + temp = Account("$loginName@$baseUrl", MainApp.getAccountType(targetContext)) + platformAccountManager.addAccountExplicitly(temp, password, null) + platformAccountManager.setUserData( + temp, + AccountUtils.Constants.KEY_OC_ACCOUNT_VERSION, + UserAccountManager.ACCOUNT_VERSION.toString() + ) + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_VERSION, "14.0.0.0") + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_OC_BASE_URL, baseUrl.toString()) + platformAccountManager.setUserData(temp, AccountUtils.Constants.KEY_USER_ID, loginName) // same as userId + + account2 = userAccountManager.getAccountByName("$loginName@$baseUrl") + account2Name = "$loginName@$baseUrl" + account2DisplayName = "User Two@$baseUrl" + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/FileDisplayActivityTest.java b/app/src/androidTest/java/com/owncloud/android/ui/activity/FileDisplayActivityTest.java new file mode 100644 index 000000000000..8f65ddcd8517 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/FileDisplayActivityTest.java @@ -0,0 +1,37 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Unpublished + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity; + +import android.app.Activity; + +import com.nextcloud.client.onboarding.WhatsNewActivity; +import com.owncloud.android.AbstractIT; + +import org.junit.Test; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; + +import static androidx.test.runner.lifecycle.Stage.RESUMED; + +public class FileDisplayActivityTest extends AbstractIT { + @Test + public void testSetupToolbar() { + try (ActivityScenario scenario = ActivityScenario.launch(FileDisplayActivity.class)) { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + Activity activity = + ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(RESUMED).iterator().next(); + if (activity instanceof WhatsNewActivity whatsNewActivity) { + whatsNewActivity.getOnBackPressedDispatcher().onBackPressed(); + } + }); + scenario.recreate(); + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/FolderPickerActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/FolderPickerActivityIT.kt new file mode 100644 index 000000000000..a597acd48cf6 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/FolderPickerActivityIT.kt @@ -0,0 +1,173 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Kilian Périsset + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import android.view.View +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FolderPickerActivityIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.activity.FolderPickerActivityIT" + + @Test + fun getActivityFile() { + launchActivity().use { scenario -> + val origin = OCFile("/test/file.test").apply { + remotePath = "/remotePath/test" + } + + var target: OCFile? = null + + scenario.onActivity { sut -> + sut.file = origin + target = sut.file + } + + assertEquals(origin, target) + } + } + + @Test + fun getParentFolder_isNotRootFolder() { + launchActivity().use { scenario -> + val origin = OCFile("/test/").apply { + fileId = 1 + remotePath = "/test/" + setStoragePath("/test/") + setFolder() + } + + var target: OCFile? = null + + scenario.onActivity { sut -> + sut.file = origin + target = sut.currentFolder + } + + assertEquals(origin, target) + } + } + + @Test + fun getParentFolder_isRootFolder() { + launchActivity().use { scenario -> + val origin = OCFile("/").apply { + fileId = 1 + remotePath = "/" + setStoragePath("/") + setFolder() + } + + var target: OCFile? = null + scenario.onActivity { sut -> + sut.file = origin + target = sut.currentFolder + } + + assertEquals(origin, target) + } + } + + @Suppress("DEPRECATION") + @Test + fun nullFile() { + launchActivity().use { scenario -> + var rootFolder: OCFile? = null + var target: OCFile? = null + + scenario.onActivity { sut -> + rootFolder = sut.storageManager.getFileByPath(OCFile.ROOT_PATH) + sut.file = null + target = sut.currentFolder + } + + assertEquals(rootFolder, target) + } + } + + @Test + fun getParentFolder() { + launchActivity().use { scenario -> + val origin = OCFile("/test/file.test").apply { + remotePath = "/test/file.test" + } + + val target = OCFile("/test/") + + scenario.onActivity { sut -> + sut.file = origin + } + + assertEquals(origin, target) + } + } + + @Test + @ScreenshotTest + fun open() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val origin = OCFile("/test/file.txt") + sut.file = origin + sut.findViewById(R.id.folder_picker_btn_copy).requestFocus() + } + + val screenShotName = createName(testClassName + "_" + "open", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun testMoveOrCopy() { + val intent = Intent(targetContext, FolderPickerActivity::class.java) + launchActivity(intent).use { scenario -> + val screenShotName = createName(testClassName + "_" + "testMoveOrCopy", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun testChooseLocationAction() { + val intent = Intent(targetContext, FolderPickerActivity::class.java).apply { + putExtra(FolderPickerActivity.EXTRA_ACTION, FolderPickerActivity.CHOOSE_LOCATION) + } + + launchActivity(intent).use { scenario -> + val screenShotName = createName(testClassName + "_" + "testChooseLocationAction", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/ManageAccountsActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/ManageAccountsActivityIT.kt new file mode 100644 index 000000000000..2d5cbe41ab88 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/ManageAccountsActivityIT.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.owncloud.android.AbstractIT +import com.owncloud.android.lib.common.Quota +import com.owncloud.android.lib.common.UserInfo +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class ManageAccountsActivityIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.activity.ManageAccountsActivityIT" + + @Test + @ScreenshotTest + fun open() { + launchActivity().use { scenario -> + val screenShotName = createName(testClassName + "_" + "open", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun userInfoDetail() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val user = sut.accountManager.user + val userInfo = UserInfo( + "test", + true, + "Test User", + "test@nextcloud.com", + "+49 123 456", + "Address 123, Berlin", + "https://www.nextcloud.com", + "https://twitter.com/Nextclouders", + Quota(), + ArrayList() + ) + sut.showUser(user, userInfo) + } + + val screenShotName = createName(testClassName + "_" + "open", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { + screenshotViaName(getCurrentActivity(), screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/PassCodeActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/PassCodeActivityIT.kt new file mode 100644 index 000000000000..276a8c723931 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/PassCodeActivityIT.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.owncloud.android.AbstractIT +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class PassCodeActivityIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.activity.PassCodeActivityIT" + + @Test + @ScreenshotTest + fun check() { + val intent = Intent(targetContext, PassCodeActivity::class.java).apply { + action = PassCodeActivity.ACTION_CHECK + } + + launchActivity(intent).use { scenario -> + scenario.onActivity { sut -> + sut.binding.txt0.clearFocus() + } + + Espresso.closeSoftKeyboard() + + val screenShotName = createName(testClassName + "_" + "check", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun request() { + val intent = Intent(targetContext, PassCodeActivity::class.java).apply { + action = PassCodeActivity.ACTION_REQUEST_WITH_RESULT + } + + launchActivity(intent).use { scenario -> + scenario.onActivity { sut -> + sut.binding.txt0.clearFocus() + } + + Espresso.closeSoftKeyboard() + + val screenShotName = createName(testClassName + "_" + "request", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun delete() { + val intent = Intent(targetContext, PassCodeActivity::class.java).apply { + action = PassCodeActivity.ACTION_CHECK_WITH_RESULT + } + + launchActivity(intent).use { scenario -> + scenario.onActivity { sut -> + sut.binding.txt0.clearFocus() + } + + Espresso.closeSoftKeyboard() + + val screenShotName = createName(testClassName + "_" + "delete", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivityIT.kt new file mode 100644 index 000000000000..a114c00dbdfa --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivityIT.kt @@ -0,0 +1,246 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import android.net.Uri +import android.view.KeyEvent +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.facebook.testing.screenshot.internal.TestNameDetector +import com.nextcloud.client.preferences.AppPreferencesImpl +import com.nextcloud.test.GrantStoragePermissionRule +import com.nextcloud.test.withSelectedText +import com.nextcloud.utils.extensions.removeFileExtension +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.ScreenshotTest +import org.hamcrest.Matchers.not +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import java.io.File + +class ReceiveExternalFilesActivityIT : AbstractIT() { + + @get:Rule + var storagePermissionRule: TestRule = GrantStoragePermissionRule.grant() + + lateinit var mainFolder: OCFile + lateinit var subFolder: OCFile + lateinit var existingImageFile: OCFile + + @Before + fun setupFolderAndFileStructure() { + // Create folders with the necessary permissions and another test file + mainFolder = OCFile("/folder/").apply { + permissions = OCFile.PERMISSION_CAN_CREATE_FILE_AND_FOLDER + setFolder() + fileDataStorageManager.saveNewFile(this) + } + subFolder = OCFile("${mainFolder.remotePath}sub folder/").apply { + permissions = OCFile.PERMISSION_CAN_CREATE_FILE_AND_FOLDER + setFolder() + fileDataStorageManager.saveNewFile(this) + } + existingImageFile = OCFile("${mainFolder.remotePath}Existing Image File.jpg").apply { + fileDataStorageManager.saveNewFile(this) + } + } + + @Test + @ScreenshotTest + fun open() { + // Screenshot name must be constructed outside of the scenario, otherwise it will not be reliably detected + val screenShotName = TestNameDetector.getTestClass() + "_" + TestNameDetector.getTestName() + launchActivity().use { scenario -> + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun openMultiAccount() { + val secondAccount = createAccount("secondtest@https://nextcloud.localhost") + open() + removeAccount(secondAccount) + } + + fun createSendIntent(file: File): Intent = Intent(targetContext, ReceiveExternalFilesActivity::class.java).apply { + action = Intent.ACTION_SEND + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file)) + } + + fun createSendIntent(files: Iterable): Intent = + Intent(targetContext, ReceiveExternalFilesActivity::class.java).apply { + action = Intent.ACTION_SEND_MULTIPLE + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(files.map { Uri.fromFile(it) })) + } + + @Test + fun renameSingleFileUpload() { + val imageFile = getDummyFile("image.jpg") + val intent = createSendIntent(imageFile) + + // Store the folder in preferences, so the activity starts from there. + @Suppress("DEPRECATION") + val preferences = AppPreferencesImpl.fromContext(targetContext) + preferences.setLastUploadPath(mainFolder.remotePath) + + launchActivity(intent).use { + val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(mainFolder, false) + // Verify that the test starts in the expected folder. If this fails, change the setup calls above + onView(withId(R.id.toolbar)) + .check(matches(hasDescendant(withText(expectedMainFolderTitle)))) + + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + // Test the pre-selection behavior (filename, but without extension, shall be selected) + onView(withId(R.id.user_input)) + .check(matches(withText(imageFile.name))) + .perform(ViewActions.click()) + .check(matches(withSelectedText(imageFile.name.removeFileExtension()))) + + // Set a new file name + val secondFileName = "New filename.jpg" + onView(withId(R.id.user_input)) + .perform(ViewActions.typeTextIntoFocusedView(secondFileName.removeFileExtension())) + .check(matches(withText(secondFileName))) + // Leave the field and come back to verify the pre-selection behavior correctly handles the new name + .perform(ViewActions.pressKey(KeyEvent.KEYCODE_TAB)) + .perform(ViewActions.click()) + .check(matches(withSelectedText(secondFileName.removeFileExtension()))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + // Set a file name without file extension + val thirdFileName = "No extension" + onView(withId(R.id.user_input)) + .perform(ViewActions.clearText()) + .perform(ViewActions.typeTextIntoFocusedView(thirdFileName)) + .check(matches(withText(thirdFileName))) + // Leave the field and come back to verify the pre-selection behavior correctly handles the new name + .perform(ViewActions.pressKey(KeyEvent.KEYCODE_TAB)) + .perform(ViewActions.click()) + .check(matches(withSelectedText(thirdFileName))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + // Test an invalid filename. Note: as the user is null, the capabilities are also null, so the name checker + // will not reject any special characters like '/'. So we only test empty and an existing file name + onView(withId(R.id.user_input)) + .perform(ViewActions.clearText()) + .check(matches(withText(""))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(not(isEnabled()))) + onView(withId(R.id.user_input)) + .perform(ViewActions.click()) + .perform(ViewActions.typeTextIntoFocusedView(existingImageFile.fileName)) + .check(matches(withText(existingImageFile.fileName))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(not(isEnabled()))) + + val fourthFileName = "New file name.jpg" + onView(withId(R.id.user_input)) + .perform(ViewActions.click()) + .perform(ViewActions.clearText()) + .perform(ViewActions.typeTextIntoFocusedView(fourthFileName)) + .check(matches(withText(fourthFileName))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + // Enter the subfolder and verify that the text stays intact + val expectedSubFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(subFolder, false) + onView(withText(expectedSubFolderTitle)) + .perform(ViewActions.click()) + onView(withId(R.id.toolbar)) + .check(matches(hasDescendant(withText(expectedSubFolderTitle)))) + onView(withId(R.id.user_input)) + .check(matches(withText(fourthFileName))) + .perform(ViewActions.click()) + .check(matches(withSelectedText(fourthFileName.removeFileExtension()))) + + // Set a new, shorter file name + val fifthFileName = "short.jpg" + onView(withId(R.id.user_input)) + .perform(ViewActions.typeTextIntoFocusedView(fifthFileName.removeFileExtension())) + .check(matches(withText(fifthFileName))) + + // Start the upload, so the folder is stored in the preferences. + // Even though the upload is expected to fail because the backend is not mocked (yet?) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + .perform(ViewActions.click()) + } + + // Start a new file receive flow. Should now start in the sub folder, but with the original filename again + launchActivity(intent).use { + val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(subFolder, false) + onView(withId(R.id.toolbar)) + .check(matches(hasDescendant(withText(expectedMainFolderTitle)))) + + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + onView(withId(R.id.user_input)) + .check(matches(withText(imageFile.name))) + } + } + + @Test + fun noRenameForMultiUpload() { + val testFiles = createDummyFiles() + val intent = createSendIntent(testFiles) + + // Store the folder in preferences, so the activity starts from there. + @Suppress("DEPRECATION") + val preferences = AppPreferencesImpl.fromContext(targetContext) + preferences.setLastUploadPath(mainFolder.remotePath) + + launchActivity(intent).use { + val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(mainFolder, false) + // Verify that the test starts in the expected folder. If this fails, change the setup calls above + onView(withId(R.id.toolbar)) + .check(matches(hasDescendant(withText(expectedMainFolderTitle)))) + + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + onView(withId(R.id.user_input)) + .check(matches(not(isDisplayed()))) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/UploadFilesActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/UploadFilesActivityIT.kt new file mode 100644 index 000000000000..d07c9ba2a7e3 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/UploadFilesActivityIT.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.GrantStoragePermissionRule.Companion.grant +import com.owncloud.android.AbstractIT +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.ScreenshotTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import java.io.File + +class UploadFilesActivityIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.activity.UploadFilesActivityIT" + + @get:Rule + var storagePermissionRule: TestRule = grant() + + private val directories = listOf("A", "B", "C", "D") + .map { File("${FileStorageUtils.getTemporalPath(account.name)}${File.separator}$it") } + + @Before + fun setUp() { + directories.forEach { it.mkdirs() } + } + + @After + fun tearDown() { + directories.forEach { it.deleteRecursively() } + } + + @Test + @ScreenshotTest + fun noneSelected() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + sut.fileListFragment.setFiles( + directories + + listOf( + File("1.txt"), + File("2.pdf"), + File("3.mp3") + ) + ) + } + + val screenShotName = createName(testClassName + "_" + "noneSelected", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut.fileListFragment.binding?.listRoot, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun localFolderPickerMode() { + val intent = Intent(targetContext, UploadFilesActivity::class.java).apply { + putExtra( + UploadFilesActivity.KEY_LOCAL_FOLDER_PICKER_MODE, + true + ) + putExtra( + UploadFilesActivity.REQUEST_CODE_KEY, + FileDisplayActivity.REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM + ) + } + + launchActivity(intent).use { scenario -> + scenario.onActivity { sut -> + sut.fileListFragment.setFiles( + directories + ) + } + + val screenShotName = createName(testClassName + "_" + "localFolderPickerMode", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun search() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + sut.fileListFragment.performSearch("1.txt", arrayListOf(), false) + sut.fileListFragment.setFiles( + directories + + listOf( + File("1.txt"), + File("2.pdf"), + File("3.mp3") + ) + ) + } + + val screenShotName = createName(testClassName + "_" + "search", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun selectAll() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + sut.fileListFragment.setFiles( + listOf( + File("1.txt"), + File("2.pdf"), + File("3.mp3") + ) + ) + sut.fileListFragment.selectAllFiles(true) + } + + val screenShotName = createName(testClassName + "_" + "selectAll", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut.fileListFragment.binding?.listRoot, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/UserInfoActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/UserInfoActivityIT.kt new file mode 100644 index 000000000000..671b50b6098a --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/UserInfoActivityIT.kt @@ -0,0 +1,54 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.owncloud.android.AbstractIT +import com.owncloud.android.lib.common.UserInfo +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class UserInfoActivityIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.activity.UserInfoActivityIT" + + @Test + @ScreenshotTest + fun fullUserInfoDetail() { + val intent = Intent(targetContext, UserInfoActivity::class.java).apply { + putExtra(UserInfoActivity.KEY_ACCOUNT, user) + + val userInfo = UserInfo( + "test", + true, + "Firstname Familyname", + "oss@rocks.com", + "+49 7613 672 255", + "Awesome Place Av.", + "https://www.nextcloud.com", + "nextclouders", + null, + null + ) + putExtra(UserInfoActivity.KEY_USER_DATA, userInfo) + } + + launchActivity(intent).use { scenario -> + val screenShotName = createName(testClassName + "_" + "fullUserInfoDetail", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/adapter/OCFileListAdapterIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/adapter/OCFileListAdapterIT.kt new file mode 100644 index 000000000000..dec36bbe3131 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/adapter/OCFileListAdapterIT.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import org.junit.Assert.assertEquals +import org.junit.Test + +@Suppress("MagicNumber") +class OCFileListAdapterIT : AbstractIT() { + @Test + fun testParseMedia() { + // empty start + storageManager.deleteAllFiles() + + val startDate: Long = 0 + val endDate: Long = 3642752043 + assertEquals(0, storageManager.getGalleryItems(startDate, endDate).size) + + // create dummy files + OCFile("/test.txt").apply { + mimeType = "text/plain" + }.let { + storageManager.saveFile(it) + } + + OCFile("/image.png").apply { + mimeType = "image/png" + modificationTimestamp = 1000000 + }.let { + storageManager.saveFile(it) + } + + OCFile("/image2.png").apply { + mimeType = "image/png" + modificationTimestamp = 1000050 + }.let { + storageManager.saveFile(it) + } + + OCFile("/video.mpg").apply { + mimeType = "video/mpg" + modificationTimestamp = 1000045 + }.let { + storageManager.saveFile(it) + } + + OCFile("/video2.avi").apply { + mimeType = "video/avi" + modificationTimestamp = endDate + 10 + }.let { + storageManager.saveFile(it) + } + + // list of remoteFiles + assertEquals(5, storageManager.allFiles.size) + assertEquals(3, storageManager.getGalleryItems(startDate, endDate).size) + assertEquals(4, storageManager.allGalleryItems.size) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapterIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapterIT.kt new file mode 100644 index 000000000000..4dbb2a4207dc --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapterIT.kt @@ -0,0 +1,416 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.adapter + +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.launchActivity +import androidx.test.platform.app.InstrumentationRegistry +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.preferences.AppPreferencesImpl +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.lib.common.SearchResultEntry +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.interfaces.UnifiedSearchCurrentDirItemAction +import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface +import com.owncloud.android.ui.unifiedsearch.ProviderID +import com.owncloud.android.ui.unifiedsearch.UnifiedSearchSection +import com.owncloud.android.utils.MimeType +import com.owncloud.android.utils.ScreenshotTest +import com.owncloud.android.utils.overlay.OverlayManager +import org.junit.Before +import org.junit.Test + +@Suppress("TooManyFunctions") +class UnifiedSearchListAdapterIT : AbstractIT() { + + private val testClassName = "com.owncloud.android.ui.adapter.UnifiedSearchListAdapterIT" + + private lateinit var overlayManager: OverlayManager + private lateinit var preferences: AppPreferences + + @Suppress("DEPRECATION") + @Before + fun setup() { + preferences = AppPreferencesImpl.fromContext(targetContext) + } + + // region Fake Data + + private fun makeFolderEntry() = SearchResultEntry( + thumbnailUrl = "", + title = "My Folder", + subline = "Documents/My Folder", + resourceUrl = "", + icon = "icon-folder", + rounded = false, + attributes = mapOf("path" to "/Documents/My Folder") + ) + + private fun makeFileEntry() = SearchResultEntry( + thumbnailUrl = "", + title = "report.pdf", + subline = "Documents/report.pdf", + resourceUrl = "", + icon = "application-pdf", + rounded = false, + attributes = mapOf("path" to "/Documents/report.pdf", "fileId" to "12345") + ) + + private fun makeContactEntry() = SearchResultEntry( + thumbnailUrl = "", + title = "John Doe", + subline = "john@example.com", + resourceUrl = "", + icon = "icon-contacts", + rounded = true, + attributes = emptyMap() + ) + + private fun makeCalendarEntry() = SearchResultEntry( + thumbnailUrl = "", + title = "Team Meeting", + subline = "Today at 10:00 AM", + resourceUrl = "", + icon = "icon-calendar", + rounded = false, + attributes = emptyMap() + ) + + private fun makeAppEntry() = SearchResultEntry( + thumbnailUrl = "", + title = "Settings", + subline = "App", + resourceUrl = "", + icon = "icon-settings", + rounded = false, + attributes = emptyMap() + ) + + private fun makeSections(vararg pairs: Pair>): List = + pairs.map { (name, entries) -> + UnifiedSearchSection( + providerID = name.lowercase().replace(" ", "_"), + name = name, + entries = entries, + hasMoreResults = false + ) + } + + private fun makeSectionsWithMore(vararg pairs: Pair>): List = + pairs.map { (name, entries) -> + UnifiedSearchSection( + providerID = name.lowercase().replace(" ", "_"), + name = name, + entries = entries, + hasMoreResults = true + ) + } + + // endregion + + // region Helpers + + private val noopListInterface = object : UnifiedSearchListInterface { + override fun onSearchResultClicked(searchResultEntry: SearchResultEntry) = Unit + override fun onLoadMoreClicked(providerID: ProviderID) = Unit + } + + private val noopCurrentDirAction = object : UnifiedSearchCurrentDirItemAction { + override fun openFile(remotePath: String, showMoreActions: Boolean) = Unit + } + + private fun setupAdapterOnActivity( + sut: FileDisplayActivity, + sections: List = emptyList(), + currentDirItems: List = emptyList(), + supportsCalendarContacts: Boolean = false + ): UnifiedSearchListAdapter { + val syncedFolderProvider = SyncedFolderProvider( + targetContext.contentResolver, + preferences, + sut.clock + ) + + val accountManager = UserAccountManagerImpl.fromContext(targetContext) + + overlayManager = OverlayManager( + syncedFolderProvider = syncedFolderProvider, + preferences = preferences, + viewThemeUtils = sut.viewThemeUtils, + context = targetContext, + accountManager = accountManager + ) + + val adapter = UnifiedSearchListAdapter( + supportsOpeningCalendarContactsLocally = supportsCalendarContacts, + storageManager = sut.storageManager, + listInterface = noopListInterface, + filesAction = object : UnifiedSearchItemViewHolder.FilesAction { + override fun showFilesAction(searchResultEntry: SearchResultEntry) = Unit + override fun loadFileThumbnail( + searchResultEntry: SearchResultEntry, + onClientReady: (com.nextcloud.common.NextcloudClient) -> Unit + ) = Unit + }, + user = sut.user.get(), + context = sut, + viewThemeUtils = sut.viewThemeUtils, + appPreferences = preferences, + currentDirItemAction = noopCurrentDirAction, + overlayManager = overlayManager + ) + + adapter.shouldShowFooters(true) + + sut.runOnUiThread { + val recyclerView = RecyclerView(sut).apply { + id = android.R.id.list + layoutManager = GridLayoutManager(sut, 1) + this.adapter = adapter + } + sut.setContentView(recyclerView) + adapter.setData(sections) + adapter.setDataCurrentDirItems(currentDirItems) + } + + return adapter + } + + private fun screenshot(sut: FileDisplayActivity, suffix: String) { + val name = createName("${testClassName}_$suffix", "") + screenshotViaName(sut, name) + } + + private fun waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + Thread.sleep(500) + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } + + // endregion + + // region Remote thumbnails (contacts, calendar, apps) + @Test + @ScreenshotTest + fun showContactEntry() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + setupAdapterOnActivity( + sut, + sections = makeSections("Contacts" to listOf(makeContactEntry())) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "contactEntry") } + } + } + + @Test + @ScreenshotTest + fun showCalendarEntry() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + setupAdapterOnActivity( + sut, + sections = makeSections("Calendar" to listOf(makeCalendarEntry())) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "calendarEntry") } + } + } + + @Test + @ScreenshotTest + fun showAppEntry() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + setupAdapterOnActivity( + sut, + sections = makeSections("Apps" to listOf(makeAppEntry())) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "appEntry") } + } + } + // endregion + + // region File entries + @Test + @ScreenshotTest + fun showFileEntry() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + setupAdapterOnActivity( + sut, + sections = makeSections("Files" to listOf(makeFileEntry())) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "fileEntry") } + } + } + + @Test + @ScreenshotTest + fun showFolderEntry() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val folder = OCFile("/Documents/My Folder").apply { + mimeType = MimeType.DIRECTORY + remoteId = "0001" + setFolder() + } + sut.storageManager.saveFile(folder) + setupAdapterOnActivity( + sut, + sections = makeSections("Files" to listOf(makeFolderEntry())) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "folderEntry") } + } + } + // endregion + + // region Multiple sections + @Test + @ScreenshotTest + fun showMultipleSections() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + setupAdapterOnActivity( + sut, + sections = makeSections( + "Files" to listOf(makeFileEntry(), makeFolderEntry()), + "Contacts" to listOf(makeContactEntry()), + "Calendar" to listOf(makeCalendarEntry()) + ) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "multipleSections") } + } + } + + @Test + @ScreenshotTest + fun showSectionsWithLoadMoreFooter() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + setupAdapterOnActivity( + sut, + sections = makeSectionsWithMore( + "Files" to listOf(makeFileEntry(), makeFolderEntry()), + "Contacts" to listOf(makeContactEntry()) + ) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "sectionsWithLoadMoreFooter") } + } + } + // endregion + + // region Current directory items + @Test + @ScreenshotTest + fun showCurrentDirectoryItems() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val file1 = OCFile("/Documents/report.pdf").apply { + mimeType = MimeType.PDF + remoteId = "0002" + fileLength = 1024 * 512 + modificationTimestamp = System.currentTimeMillis() + } + val file2 = OCFile("/Documents/photo.jpg").apply { + mimeType = MimeType.JPEG + remoteId = "0003" + fileLength = 1024 * 1024 + modificationTimestamp = System.currentTimeMillis() + } + setupAdapterOnActivity( + sut, + currentDirItems = listOf(file1, file2) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "currentDirectoryItems") } + } + } + + @Test + @ScreenshotTest + fun showCurrentDirectoryItemsWithUnifiedSearchSections() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val localFile = OCFile("/Documents/notes.txt").apply { + mimeType = MimeType.TEXT_PLAIN + remoteId = "00005" + fileLength = 1024 + modificationTimestamp = System.currentTimeMillis() + } + setupAdapterOnActivity( + sut, + sections = makeSections("Files" to listOf(makeFileEntry())), + currentDirItems = listOf(localFile) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "currentDirItemsWithUnifiedSections") } + } + } + // endregion + + // region Scroll / recycle stress test + @Test + @ScreenshotTest + fun showManyEntries() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val fileEntries = (1..10).map { i -> + SearchResultEntry( + thumbnailUrl = "", + title = "File $i.pdf", + subline = "Documents/File $i.pdf", + resourceUrl = "", + icon = "application-pdf", + rounded = false, + attributes = mapOf("fileId" to "$i") + ) + } + val contactEntries = (1..5).map { i -> + SearchResultEntry( + thumbnailUrl = "", + title = "Contact $i", + subline = "user$i@example.com", + resourceUrl = "", + icon = "icon-contacts", + rounded = true, + attributes = emptyMap() + ) + } + setupAdapterOnActivity( + sut, + sections = makeSections( + "Files" to fileEntries, + "Contacts" to contactEntries + ) + ) + } + waitForIdle() + scenario.onActivity { sut -> screenshot(sut, "manyEntriesScrollStress") } + } + } + // endregion +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt new file mode 100644 index 000000000000..5c4707f99e06 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt @@ -0,0 +1,738 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.dialog + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.app.Dialog +import android.content.Intent +import android.net.http.SslCertificate +import android.net.http.SslError +import android.os.Looper +import android.view.ViewGroup +import android.webkit.SslErrorHandler +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContract +import androidx.fragment.app.DialogFragment +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.rule.GrantPermissionRule +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.nextcloud.android.common.ui.color.ColorUtil +import com.nextcloud.android.lib.resources.profile.Action +import com.nextcloud.android.lib.resources.profile.HoverCard +import com.nextcloud.client.account.RegisteredUser +import com.nextcloud.client.account.Server +import com.nextcloud.client.device.DeviceInfo +import com.nextcloud.client.documentscan.AppScanOptionalFeature +import com.nextcloud.ui.ChooseAccountDialogFragment +import com.nextcloud.ui.ChooseAccountDialogFragment.Companion.newInstance +import com.nextcloud.ui.SetOnlineStatusBottomSheet +import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance +import com.nextcloud.utils.EditorUtils +import com.owncloud.android.AbstractIT +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.authentication.EnforcedServer +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.Creator +import com.owncloud.android.lib.common.DirectEditing +import com.owncloud.android.lib.common.Editor +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.accounts.AccountTypeUtils +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.lib.resources.status.OwnCloudVersion +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.lib.resources.users.StatusType +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.fragment.OCFileListBottomSheetActions +import com.owncloud.android.ui.fragment.OCFileListBottomSheetDialog +import com.owncloud.android.ui.fragment.ProfileBottomSheetDialog +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.ScreenshotTest +import com.owncloud.android.utils.theme.CapabilityUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import java.net.URI +import java.util.function.Supplier + +@Suppress("TooManyFunctions") +class DialogFragmentIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.dialog.DialogFragmentIT" + private val serverUrl = "https://nextcloud.localhost" + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.POST_NOTIFICATIONS + ) + + @After + fun quitLooperIfNeeded() { + Looper.myLooper()?.quitSafely() + } + + @Test + @ScreenshotTest + fun testRenameFileDialog() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + + RenameFileDialogFragment.newInstance( + OCFile("/Test/"), + OCFile("/") + ).run { + showDialog(this) + } + } + + @Test + @ScreenshotTest + fun testLoadingDialog() { + LoadingDialog.newInstance("Wait…").run { + showDialog(this) + } + } + + @Test + @ScreenshotTest + fun testConfirmationDialogWithOneAction() { + ConfirmationDialogFragment.newInstance( + R.string.upload_list_empty_text_auto_upload, + arrayOf(), + R.string.filedetails_sync_file, + R.drawable.ic_warning, + -1, + -1, + -1 + ).run { + showDialog(this) + } + } + + @Test + @ScreenshotTest + fun testConfirmationDialogWithTwoAction() { + ConfirmationDialogFragment.newInstance( + R.string.upload_list_empty_text_auto_upload, + arrayOf(), + R.string.filedetails_sync_file, + R.drawable.ic_warning, + R.string.common_cancel, + -1, + -1 + ).run { + showDialog(this) + } + } + + @Test + @ScreenshotTest + fun testConfirmationDialogWithThreeAction() { + ConfirmationDialogFragment.newInstance( + R.string.upload_list_empty_text_auto_upload, + arrayOf(), + R.string.filedetails_sync_file, + R.drawable.ic_warning, + R.string.common_cancel, + R.string.common_confirm, + -1 + ).run { + showDialog(this) + } + } + + @Test + @ScreenshotTest + fun testConfirmationDialogWithThreeActionRTL() { + enableRTL() + ConfirmationDialogFragment.newInstance( + R.string.upload_list_empty_text_auto_upload, + arrayOf(), + -1, + R.drawable.ic_warning, + R.string.common_cancel, + R.string.common_confirm, + -1 + ).run { + showDialog(this) + resetLocale() + } + } + + @Test + @ScreenshotTest + fun testRemoveFileDialog() { + RemoveFilesDialogFragment.newInstance(OCFile("/Test.md")).run { + showDialog(this) + } + } + + @Test + @ScreenshotTest + fun testRemoveFilesDialog() { + val toDelete = ArrayList().apply { + add(OCFile("/Test.md")) + add(OCFile("/Document.odt")) + } + + val dialog: RemoveFilesDialogFragment = RemoveFilesDialogFragment.newInstance(toDelete) + showDialog(dialog) + } + + @Test + @ScreenshotTest + fun testRemoveFolderDialog() { + val dialog = RemoveFilesDialogFragment.newInstance(OCFile("/Folder/")) + showDialog(dialog) + } + + @Test + @ScreenshotTest + fun testRemoveFoldersDialog() { + val toDelete = ArrayList() + toDelete.add(OCFile("/Folder/")) + toDelete.add(OCFile("/Documents/")) + + val dialog: RemoveFilesDialogFragment = RemoveFilesDialogFragment.newInstance(toDelete) + showDialog(dialog) + } + + @Test + @ScreenshotTest + fun testNewFolderDialog() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + val sut = CreateFolderDialogFragment.newInstance(OCFile("/")) + showDialog(sut) + } + + @Test + @ScreenshotTest + fun testEnforcedPasswordDialog() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + val sut = SharePasswordDialogFragment.newInstance(OCFile("/"), createShare = true, askForPassword = false) + showDialog(sut) + } + + @Test + @ScreenshotTest + fun testOptionalPasswordDialog() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + val sut = SharePasswordDialogFragment.newInstance(OCFile("/"), createShare = true, askForPassword = true) + showDialog(sut) + } + + @Test + @ScreenshotTest + fun testAccountChooserDialog() { + val intent = Intent(targetContext, FileDisplayActivity::class.java) + ActivityScenario.launch(intent).use { scenario -> + var sut: ChooseAccountDialogFragment? = null + + scenario.onActivity { activity: FileDisplayActivity -> + + val userAccountManager = activity.userAccountManager + val accountManager = AccountManager.get(targetContext) + for (account in accountManager.getAccountsByType(MainApp.getAccountType(targetContext))) { + accountManager.removeAccountExplicitly(account) + } + + val newAccount = Account("test@https://nextcloud.localhost", MainApp.getAccountType(targetContext)) + accountManager.addAccountExplicitly(newAccount, "password", null) + accountManager.setUserData(newAccount, AccountUtils.Constants.KEY_OC_BASE_URL, serverUrl) + accountManager.setUserData(newAccount, AccountUtils.Constants.KEY_USER_ID, "test") + accountManager.setAuthToken( + newAccount, + AccountTypeUtils.getAuthTokenTypePass(newAccount.type), + "password" + ) + val newUser = userAccountManager.getUser(newAccount.name) + .orElseThrow(Supplier { RuntimeException() }) + userAccountManager.setCurrentOwnCloudAccount(newAccount.name) + + val newAccount2 = Account("user1@nextcloud.localhost", MainApp.getAccountType(targetContext)) + accountManager.addAccountExplicitly(newAccount2, "password", null) + accountManager.setUserData(newAccount2, AccountUtils.Constants.KEY_OC_BASE_URL, serverUrl) + accountManager.setUserData(newAccount2, AccountUtils.Constants.KEY_USER_ID, "user1") + accountManager.setUserData(newAccount2, AccountUtils.Constants.KEY_OC_VERSION, "20.0.0") + accountManager.setAuthToken( + newAccount2, + AccountTypeUtils.getAuthTokenTypePass(newAccount.type), + "password" + ) + + val fileDataStorageManager = FileDataStorageManager( + newUser, + targetContext.contentResolver + ) + + val capability = OCCapability().apply { + userStatus = CapabilityBooleanType.TRUE + userStatusSupportsEmoji = CapabilityBooleanType.TRUE + } + fileDataStorageManager.saveCapabilities(capability) + + sut = newInstance( + RegisteredUser( + newAccount, + OwnCloudAccount(newAccount, targetContext), + Server(URI.create(serverUrl), OwnCloudVersion.nextcloud_20) + ) + ) + + sut.show(activity.supportFragmentManager, null) + } + + val dialogInstance = waitForDialog(sut!!) ?: throw IllegalStateException("Dialog was not created") + + scenario.onActivity { + val viewGroup = dialogInstance.window?.findViewById(android.R.id.content) + if (viewGroup != null) { + hideCursors(viewGroup) + } + + sut.setStatus( + Status( + StatusType.DND, + "Busy fixing 🐛…", + "", + -1 + ), + targetContext + ) + screenshot(sut, "dnd") + + sut.setStatus( + Status( + StatusType.ONLINE, + "", + "", + -1 + ), + targetContext + ) + screenshot(sut, "online") + + sut.setStatus( + Status( + StatusType.ONLINE, + "Let's have some fun", + "🎉", + -1 + ), + targetContext + ) + screenshot(sut, "fun") + + sut.setStatus( + Status(StatusType.OFFLINE, "", "", -1), + targetContext + ) + screenshot(sut, "offline") + + sut.setStatus( + Status(StatusType.AWAY, "Vacation", "🌴", -1), + targetContext + ) + screenshot(sut, "away") + } + } + } + + @Test + @ScreenshotTest + @Throws(AccountUtils.AccountNotFoundException::class) + fun testAccountChooserDialogWithStatusDisabled() { + val accountManager = AccountManager.get(targetContext) + for (account in accountManager.accounts) { + accountManager.removeAccountExplicitly(account) + } + + val newAccount = Account("test@https://nextcloud.localhost", MainApp.getAccountType(targetContext)) + accountManager.addAccountExplicitly(newAccount, "password", null) + accountManager.setUserData(newAccount, AccountUtils.Constants.KEY_OC_BASE_URL, serverUrl) + accountManager.setUserData(newAccount, AccountUtils.Constants.KEY_USER_ID, "test") + accountManager.setAuthToken(newAccount, AccountTypeUtils.getAuthTokenTypePass(newAccount.type), "password") + + launchActivity().use { scenario -> + var sut: ChooseAccountDialogFragment? = null + scenario.onActivity { fda -> + val userAccountManager = fda.userAccountManager + val newUser = userAccountManager.getUser(newAccount.name).get() + val fileDataStorageManager = FileDataStorageManager( + newUser, + targetContext.contentResolver + ) + + val capability = OCCapability().apply { + userStatus = CapabilityBooleanType.FALSE + } + + fileDataStorageManager.saveCapabilities(capability) + + sut = newInstance( + RegisteredUser( + newAccount, + OwnCloudAccount(newAccount, targetContext), + Server( + URI.create(serverUrl), + OwnCloudVersion.nextcloud_20 + ) + ) + ) + + sut.show(fda.supportFragmentManager, null) + } + + waitForDialog(sut!!) ?: throw IllegalStateException("Dialog was not created") + + onView(isRoot()).check(matches(isDisplayed())) + } + } + + @Test + @ScreenshotTest + fun testBottomSheet() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + + val action: OCFileListBottomSheetActions = object : OCFileListBottomSheetActions { + override fun createFolder(encrypted: Boolean) = Unit + override fun uploadFromApp() = Unit + override fun uploadFiles() = Unit + override fun newDocument() = Unit + override fun newSpreadsheet() = Unit + override fun newPresentation() = Unit + override fun directCameraUpload() = Unit + override fun scanDocUpload() = Unit + override fun scanDocUploadFromApp() = Unit + override val isScanDocUploadFromAppAvailable: Boolean + get() = false + override fun showTemplate(creator: Creator?, headline: String?) = Unit + override fun createRichWorkspace() = Unit + } + + val info = DeviceInfo() + val ocFile = OCFile("/test.md").apply { + remoteId = "00000001" + } + + val intent = Intent(targetContext, FileDisplayActivity::class.java) + + launchActivity(intent).use { scenario -> + var sut: OCFileListBottomSheetDialog? = null + scenario.onActivity { fda -> + var directEditing = DirectEditing() + val creators = directEditing.creators.toMutableMap() + val editors = directEditing.editors.toMutableMap() + + creators["1"] = Creator( + "1", + "text", + "text file", + ".md", + "application/octet-stream", + false + ) + + creators["2"] = Creator( + "2", + "md", + "markdown file", + ".md", + "application/octet-stream", + false + ) + + editors["text"] = Editor( + "1", + "Text", + ArrayList(mutableListOf(MimeTypeUtil.MIMETYPE_TEXT_MARKDOWN)), + ArrayList(), + false + ) + + directEditing = DirectEditing(editors, creators) + val json = Gson().toJson(directEditing) + + ArbitraryDataProviderImpl(targetContext).storeOrUpdateKeyValue( + user.accountName, + ArbitraryDataProvider.DIRECT_EDITING, + json + ) + + val optionalCapability = fda.capabilities + if (optionalCapability.isEmpty) { + fail("capabilities is empty") + } + + val capability = optionalCapability.get().apply { + richDocuments = CapabilityBooleanType.TRUE + richDocumentsDirectEditing = CapabilityBooleanType.TRUE + richDocumentsTemplatesAvailable = CapabilityBooleanType.TRUE + accountName = user.accountName + } + CapabilityUtils.updateCapability(capability) + + val appScanOptionalFeature: AppScanOptionalFeature = object : AppScanOptionalFeature() { + override fun getScanContract(): ActivityResultContract = + throw UnsupportedOperationException("Document scan is not available") + } + + val viewThemeUtils = ViewThemeUtils( + materialSchemesForCurrentUser, + ColorUtil(targetContext) + ) + + val editorUtils = EditorUtils(ArbitraryDataProviderImpl(targetContext)) + + sut = OCFileListBottomSheetDialog( + fda, + action, + info, + user, + ocFile, + fda.themeUtils, + viewThemeUtils, + editorUtils, + appScanOptionalFeature + ) + + sut.show() + } + + sut!!.behavior.setState(BottomSheetBehavior.STATE_EXPANDED) + val viewGroup = sut.window?.findViewById(android.R.id.content) ?: return + hideCursors(viewGroup) + + val screenShotName = createName(testClassName + "_" + "testBottomSheet", "") + onView(isRoot()).check(matches(isDisplayed())) + + screenshotViaName(sut.window?.decorView, screenShotName) + } + } + + @Test + @ScreenshotTest + fun testOnlineStatusBottomSheet() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + + val intent = Intent(targetContext, FileDisplayActivity::class.java) + + launchActivity(intent).use { scenario -> + var sut: SetOnlineStatusBottomSheet? = null + scenario.onActivity { fda -> + sut = SetOnlineStatusBottomSheet( + Status(StatusType.DND, "Focus time", "\uD83E\uDD13", -1) + ) + sut.show(fda.supportFragmentManager, "set_online_status") + } + + val screenShotName = createName(testClassName + "_" + "testOnlineStatusBottomSheet", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { + screenshotViaName(sut!!.view, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun testProfileBottomSheet() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + + val actions: MutableList = ArrayList() + actions.add( + Action( + "profile", + "View profile", + "https://dev.nextcloud.com/core/img/actions/profile.svg", + "https://dev.nextcloud.com/index.php/u/christine" + ) + ) + actions.add( + Action( + "core", + "christine.scott@nextcloud.com", + "https://dev.nextcloud.com/core/img/actions/mail.svg", + "mailto:christine.scott@nextcloud.com" + ) + ) + + actions.add( + Action( + "spreed", + "Talk to Christine", + "https://dev.nextcloud.com/apps/spreed/img/app-dark.svg", + "https://dev.nextcloud.com/apps/spreed/?callUser=christine" + ) + ) + + val hoverCard = HoverCard("christine", "Christine Scott", actions) + val intent = Intent(targetContext, FileDisplayActivity::class.java) + + launchActivity(intent).use { scenario -> + var sut: ProfileBottomSheetDialog? = null + scenario.onActivity { fda -> + sut = ProfileBottomSheetDialog( + fda, + user, + hoverCard, + fda.viewThemeUtils + ) + sut.show() + } + + val screenShotName = createName(testClassName + "_" + "testProfileBottomSheet", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { + screenshotViaName(sut!!.window?.decorView, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun testSslUntrustedCertDialog() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + + val certificate = SslCertificate("foo", "bar", "2022/01/10", "2022/01/30") + val sslError = SslError(SslError.SSL_UNTRUSTED, certificate) + + val handler = mockk(relaxed = true) + + SslUntrustedCertDialog.newInstanceForEmptySslError(sslError, handler).run { + showDialog(this) + } + } + + @Test + @ScreenshotTest + fun testStoragePermissionDialog() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + + val dialog = StoragePermissionDialogFragment() + showDialog(dialog) + } + + @Test + @ScreenshotTest + fun testFileActionsBottomSheet() { + if (Looper.myLooper() == null) { + Looper.prepare() + } + + val ocFile = OCFile("/test.md").apply { + remoteId = "0001" + } + + newInstance(ocFile, false).run { + showDialog(this) + } + } + + private fun showDialog(dialog: DialogFragment) { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + dialog.show(sut.supportFragmentManager, null) + } + + val dialogInstance = waitForDialog(dialog) ?: throw IllegalStateException("Dialog was not created") + + scenario.onActivity { + val viewGroup = dialogInstance.window?.findViewById(android.R.id.content) + if (viewGroup != null) { + hideCursors(viewGroup) + } + screenshot(dialogInstance.window?.decorView) + } + + onView(isRoot()).check(matches(isDisplayed())) + } + } + + private fun waitForDialog(dialogFragment: DialogFragment, timeoutMs: Long = 5000): Dialog? { + val start = System.currentTimeMillis() + while (System.currentTimeMillis() - start < timeoutMs) { + if (dialogFragment.isAdded && dialogFragment.dialog != null) { + return dialogFragment.dialog + } + Thread.sleep(50) + } + return null + } + + private fun hideCursors(viewGroup: ViewGroup) { + for (i in 0..().apply { + add(EnforcedServer("name", "url")) + add(EnforcedServer("name2", "url1")) + } + + val s = Gson().toJson(t) + val t2 = Gson().fromJson>( + s, + object : TypeToken?>() { + }.type + ) + + val temp = ArrayList() + for (p in t2) { + temp.add(p.name) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/dialog/SendFilesDialogTest.kt b/app/src/androidTest/java/com/owncloud/android/ui/dialog/SendFilesDialogTest.kt new file mode 100644 index 000000000000..22cb178ef271 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/dialog/SendFilesDialogTest.kt @@ -0,0 +1,107 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.dialog + +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Assert +import org.junit.Test + +class SendFilesDialogTest : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.dialog.SendFilesDialogTest" + + companion object { + private val FILES_SAME_TYPE = setOf( + OCFile("/1.jpg").apply { + mimeType = "image/jpg" + }, + OCFile("/2.jpg").apply { + mimeType = "image/jpg" + } + ) + private val FILES_MIXED_TYPE = setOf( + OCFile("/1.jpg").apply { + mimeType = "image/jpg" + }, + OCFile("/2.pdf").apply { + mimeType = "application/pdf" + }, + OCFile("/3.png").apply { + mimeType = "image/png" + } + ) + } + + private fun showDialog(files: Set, onComplete: (SendFilesDialog) -> Unit) { + launchActivity().use { scenario -> + onView(isRoot()).check(matches(isDisplayed())) + + var dialog: SendFilesDialog? = null + scenario.onActivity { sut -> + val fm: FragmentManager = sut.supportFragmentManager + val ft = fm.beginTransaction() + ft.addToBackStack(null) + dialog = SendFilesDialog.newInstance(files) + dialog.show(ft, "TAG_SEND_SHARE_DIALOG") + fm.executePendingTransactions() + } + + onComplete(dialog!!) + } + } + + @Test + @ScreenshotTest + fun showDialog() { + showDialog(FILES_SAME_TYPE) { sut -> + val recyclerview: RecyclerView = sut.requireDialog().findViewById(R.id.send_button_recycler_view) + Assert.assertNotNull("Adapter is null", recyclerview.adapter) + Assert.assertNotEquals("Send button list is empty", 0, recyclerview.adapter!!.itemCount) + } + } + + @Test + @ScreenshotTest + fun showDialog_Screenshot() { + showDialog(FILES_SAME_TYPE) { sut -> + val screenShotName = createName(testClassName + "_" + "showDialog_Screenshot", "") + screenshotViaName(sut.requireDialog().window?.decorView, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showDialogDifferentTypes() { + showDialog(FILES_MIXED_TYPE) { sut -> + val recyclerview: RecyclerView = sut.requireDialog().findViewById(R.id.send_button_recycler_view) + Assert.assertNotNull("Adapter is null", recyclerview.adapter) + Assert.assertNotEquals("Send button list is empty", 0, recyclerview.adapter!!.itemCount) + } + } + + @Test + @ScreenshotTest + fun showDialogDifferentTypes_Screenshot() { + showDialog(FILES_MIXED_TYPE) { sut -> + val screenShotName = createName(testClassName + "_" + "showDialogDifferentTypes_Screenshot", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut.requireDialog().window?.decorView, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/dialog/SendShareDialogTest.kt b/app/src/androidTest/java/com/owncloud/android/ui/dialog/SendShareDialogTest.kt new file mode 100644 index 000000000000..f0c47eed68e2 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/dialog/SendShareDialogTest.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.dialog + +import androidx.fragment.app.FragmentManager +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class SendShareDialogTest : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.dialog.SendShareDialogTest" + + @Test + @ScreenshotTest + fun showDialog() { + launchActivity().use { scenario -> + var sut: SendShareDialog? = null + scenario.onActivity { activity -> + val fm: FragmentManager = activity.supportFragmentManager + val ft = fm.beginTransaction() + ft.addToBackStack(null) + fm.executePendingTransactions() + val file = OCFile("/1.jpg").apply { + mimeType = "image/jpg" + } + + sut = SendShareDialog.newInstance(file, false, OCCapability()) + sut.show(ft, "TAG_SEND_SHARE_DIALOG") + } + + val screenShotName = createName(testClassName + "_" + "showDialog", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut!!.requireDialog().window?.decorView, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/dialog/SetupEncryptionDialogFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/dialog/SetupEncryptionDialogFragmentIT.kt new file mode 100644 index 000000000000..576b11515063 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/dialog/SetupEncryptionDialogFragmentIT.kt @@ -0,0 +1,80 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.dialog + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class SetupEncryptionDialogFragmentIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.dialog.SetupEncryptionDialogFragmentIT" + + @Test + @ScreenshotTest + fun showMnemonic() { + launchActivity().use { scenario -> + var sut: SetupEncryptionDialogFragment? = null + scenario.onActivity { activity -> + sut = SetupEncryptionDialogFragment.newInstance(user, null) + sut.show(activity.supportFragmentManager, "1") + val keyWords = arrayListOf( + "ability", + "able", + "about", + "above", + "absent", + "absorb", + "abstract", + "absurd", + "abuse", + "access", + "accident", + "account", + "accuse" + ) + sut.setMnemonic(keyWords) + sut.showMnemonicInfo() + } + + val screenShotName = createName(testClassName + "_" + "showMnemonic", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { + screenshotViaName(sut!!.requireDialog().window?.decorView, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun error() { + launchActivity().use { scenario -> + var sut: SetupEncryptionDialogFragment? = null + scenario.onActivity { activity -> + sut = SetupEncryptionDialogFragment.newInstance(user, null) + sut.show(activity.supportFragmentManager, "1") + sut.errorSavingKeys() + } + + val screenShotName = createName(testClassName + "_" + "error", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { + screenshotViaName(sut!!.requireDialog().window?.decorView, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/dialog/SyncFileNotEnoughSpaceDialogFragmentTest.kt b/app/src/androidTest/java/com/owncloud/android/ui/dialog/SyncFileNotEnoughSpaceDialogFragmentTest.kt new file mode 100644 index 000000000000..f83e116a6da5 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/dialog/SyncFileNotEnoughSpaceDialogFragmentTest.kt @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.dialog + +import android.Manifest +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.rule.GrantPermissionRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragment.Companion.newInstance +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Rule +import org.junit.Test + +class SyncFileNotEnoughSpaceDialogFragmentTest : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragmentTest" + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.POST_NOTIFICATIONS + ) + + @Test + @ScreenshotTest + fun showNotEnoughSpaceDialogForFolder() { + launchActivity().use { scenario -> + var sut: FileDisplayActivity? = null + scenario.onActivity { activity -> + val ocFile = OCFile("/Document/").apply { + fileLength = 5000000 + setFolder() + } + + newInstance(ocFile, 1000).apply { + show(activity.supportFragmentManager, "1") + } + + sut = activity + } + + val screenShotName = createName(testClassName + "_" + "showNotEnoughSpaceDialogForFolder", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showNotEnoughSpaceDialogForFile() { + launchActivity().use { scenario -> + var sut: FileDisplayActivity? = null + scenario.onActivity { activity -> + val ocFile = OCFile("/Video.mp4").apply { + fileLength = 1000000 + } + + newInstance(ocFile, 2000).apply { + show(activity.supportFragmentManager, "2") + } + sut = activity + } + + val screenShotName = createName(testClassName + "_" + "showNotEnoughSpaceDialogForFile", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/AvatarIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/AvatarIT.kt new file mode 100644 index 000000000000..7697399d8688 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/AvatarIT.kt @@ -0,0 +1,185 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import android.graphics.BitmapFactory +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.lib.resources.users.StatusType +import com.owncloud.android.ui.TextDrawable +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class AvatarIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.fragment.AvatarIT" + + @Test + @ScreenshotTest + fun showAvatars() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val fragment = AvatarTestFragment() + sut.addFragment(fragment) + } + + onView(withId(R.id.avatar_list1)) + .check(matches(isDisplayed())) + + scenario.onActivity { sut -> + val avatarRadius = targetContext.resources.getDimension(R.dimen.list_item_avatar_icon_radius) + val width = DisplayUtils.convertDpToPixel(2 * avatarRadius, targetContext) + val fragment = sut.supportFragmentManager.fragments.last() as AvatarTestFragment + + fragment.run { + addAvatar("Admin", avatarRadius, width, targetContext) + addAvatar("Test Server Admin", avatarRadius, width, targetContext) + addAvatar("Cormier Paulette", avatarRadius, width, targetContext) + addAvatar("winston brent", avatarRadius, width, targetContext) + addAvatar("Baker James Lorena", avatarRadius, width, targetContext) + addAvatar("Baker James Lorena", avatarRadius, width, targetContext) + addAvatar("email@nextcloud.localhost", avatarRadius, width, targetContext) + } + } + + val screenShotName = createName(testClassName + "_" + "showAvatars", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun showAvatarsWithStatus() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val fragment = AvatarTestFragment() + sut.addFragment(fragment) + } + + onView(withId(R.id.avatar_list1)) + .check(matches(isDisplayed())) + + scenario.onActivity { sut -> + val avatarRadius = targetContext.resources.getDimension(R.dimen.list_item_avatar_icon_radius) + val width = DisplayUtils.convertDpToPixel(2 * avatarRadius, targetContext) + + val paulette = BitmapFactory.decodeFile(getFile("paulette.jpg").absolutePath) + val christine = BitmapFactory.decodeFile(getFile("christine.jpg").absolutePath) + val textBitmap = BitmapUtils.drawableToBitmap(TextDrawable.createNamedAvatar("Admin", avatarRadius)) + val fragment = sut.supportFragmentManager.fragments.last() as AvatarTestFragment + + fragment.run { + addBitmap( + BitmapUtils.createAvatarWithStatus(paulette, StatusType.ONLINE, "😘", targetContext), + width * 2, + 1, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(christine, StatusType.ONLINE, "☁️", targetContext), + width * 2, + 1, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(christine, StatusType.ONLINE, "🌴️", targetContext), + width * 2, + 1, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(christine, StatusType.ONLINE, "", targetContext), + width * 2, + 1, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(paulette, StatusType.DND, "", targetContext), + width * 2, + 1, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(christine, StatusType.AWAY, "", targetContext), + width * 2, + 1, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(paulette, StatusType.OFFLINE, "", targetContext), + width * 2, + 1, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(textBitmap, StatusType.ONLINE, "😘", targetContext), + width, + 2, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(textBitmap, StatusType.ONLINE, "☁️", targetContext), + width, + 2, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(textBitmap, StatusType.ONLINE, "🌴️", targetContext), + width, + 2, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(textBitmap, StatusType.ONLINE, "", targetContext), + width, + 2, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(textBitmap, StatusType.DND, "", targetContext), + width, + 2, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(textBitmap, StatusType.AWAY, "", targetContext), + width, + 2, + targetContext + ) + addBitmap( + BitmapUtils.createAvatarWithStatus(textBitmap, StatusType.OFFLINE, "", targetContext), + width, + 2, + targetContext + ) + } + } + + val screenShotName = createName(testClassName + "_" + "showAvatarsWithStatus", "") + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/AvatarTestFragment.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/AvatarTestFragment.kt new file mode 100644 index 000000000000..6d21bf1239f9 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/AvatarTestFragment.kt @@ -0,0 +1,69 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import android.content.Context +import android.graphics.Bitmap +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.RelativeLayout +import androidx.fragment.app.Fragment +import com.owncloud.android.R +import com.owncloud.android.ui.TextDrawable + +internal class AvatarTestFragment : Fragment() { + private lateinit var list1: LinearLayout + private lateinit var list2: LinearLayout + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view: View = inflater.inflate(R.layout.avatar_fragment, null) + + list1 = view.findViewById(R.id.avatar_list1) + list2 = view.findViewById(R.id.avatar_list2) + + return view + } + + fun addAvatar(name: String, avatarRadius: Float, width: Int, targetContext: Context) { + val margin = PADDING + val imageView = ImageView(targetContext) + imageView.setImageDrawable(TextDrawable.createNamedAvatar(name, avatarRadius)) + + val layoutParams: RelativeLayout.LayoutParams = RelativeLayout.LayoutParams(width, width) + layoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT) + layoutParams.setMargins(margin, margin, margin, margin) + imageView.layoutParams = layoutParams + + list1.addView(imageView) + } + + fun addBitmap(bitmap: Bitmap, width: Int, list: Int, targetContext: Context) { + val margin = PADDING + val imageView = ImageView(targetContext) + imageView.setImageBitmap(bitmap) + + val layoutParams: RelativeLayout.LayoutParams = RelativeLayout.LayoutParams(width, width) + layoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT) + layoutParams.setMargins(margin, margin, margin, margin) + imageView.layoutParams = layoutParams + + if (list == 1) { + list1.addView(imageView) + } else { + list2.addView(imageView) + } + } + + companion object { + private const val PADDING = 10 + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/BackupListFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/BackupListFragmentIT.kt new file mode 100644 index 000000000000..8f8580fc1ef1 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/BackupListFragmentIT.kt @@ -0,0 +1,160 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import android.Manifest +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.rule.GrantPermissionRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.ContactsPreferenceActivity +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment +import com.owncloud.android.utils.EspressoIdlingResource +import com.owncloud.android.utils.ScreenshotTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class BackupListFragmentIT : AbstractIT() { + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR) + + private val testClassName = "com.owncloud.android.ui.fragment.BackupListFragmentIT" + + @Before + fun registerIdlingResource() { + IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource) + } + + @After + fun unregisterIdlingResource() { + IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource) + } + + @Test + @ScreenshotTest + fun showLoading() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val file = OCFile("/") + val transaction = sut.supportFragmentManager.beginTransaction() + + onIdleSync { + EspressoIdlingResource.increment() + + transaction.replace(R.id.frame_container, BackupListFragment.newInstance(file, user)) + transaction.commit() + + EspressoIdlingResource.decrement() + + val screenShotName = createName(testClassName + "_" + "showLoading", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + } + } + + @Test + @ScreenshotTest + fun showContactList() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val transaction = sut.supportFragmentManager.beginTransaction() + val file = getFile("vcard.vcf") + val ocFile = OCFile("/vcard.vcf").apply { + storagePath = file.absolutePath + mimeType = "text/vcard" + } + + onIdleSync { + EspressoIdlingResource.increment() + + transaction.replace(R.id.frame_container, BackupListFragment.newInstance(ocFile, user)) + transaction.commit() + + EspressoIdlingResource.decrement() + + val screenShotName = createName(testClassName + "_" + "showContactList", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + } + } + + @Test + @ScreenshotTest + fun showCalendarList() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val transaction = sut.supportFragmentManager.beginTransaction() + val file = getFile("calendar.ics") + val ocFile = OCFile("/Private calender_2020-09-01_10-45-20.ics.ics").apply { + storagePath = file.absolutePath + mimeType = "text/calendar" + } + + onIdleSync { + EspressoIdlingResource.increment() + + transaction.replace(R.id.frame_container, BackupListFragment.newInstance(ocFile, user)) + transaction.commit() + + EspressoIdlingResource.decrement() + + val screenShotName = createName(testClassName + "_" + "showCalendarList", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + } + } + + @Test + @ScreenshotTest + fun showCalendarAndContactsList() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val transaction = sut.supportFragmentManager.beginTransaction() + val calendarFile = getFile("calendar.ics") + val calendarOcFile = OCFile("/Private calender_2020-09-01_10-45-20.ics.ics").apply { + storagePath = calendarFile.absolutePath + mimeType = "text/calendar" + } + + val contactFile = getFile("vcard.vcf") + val contactOcFile = OCFile("/vcard.vcf").apply { + storagePath = contactFile.absolutePath + mimeType = "text/vcard" + } + + val files = arrayOf(calendarOcFile, contactOcFile) + + onIdleSync { + EspressoIdlingResource.increment() + + transaction.replace(R.id.frame_container, BackupListFragment.newInstance(files, user)) + transaction.commit() + + EspressoIdlingResource.decrement() + + val screenShotName = createName(testClassName + "_" + "showCalendarAndContactsList", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt new file mode 100644 index 000000000000..da634d4d62c6 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt @@ -0,0 +1,225 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.TestActivity +import com.nextcloud.ui.ImageDetailFragment +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.activities.model.Activity +import com.owncloud.android.lib.resources.activities.model.RichElement +import com.owncloud.android.lib.resources.activities.model.RichObject +import com.owncloud.android.lib.resources.activities.models.PreviewObject +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test +import java.io.File +import java.util.GregorianCalendar + +class FileDetailFragmentStaticServerIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT" + + private var file: File = getFile("gps.jpg") + private val oCFile: OCFile = OCFile("/").apply { + storagePath = file.absolutePath + fileId = 12 + fileDataStorageManager.saveFile(this) + } + + @Test + @ScreenshotTest + fun showFileDetailActivitiesFragment() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { sut -> + activity = sut + sut.addFragment(FileDetailActivitiesFragment.newInstance(oCFile, user)) + sut.supportFragmentManager.executePendingTransactions() + } + + val screenShotName = createName(testClassName + "_" + "showFileDetailActivitiesFragment", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showFileDetailSharingFragment() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { sut -> + activity = sut + sut.addFragment(FileDetailSharingFragment.newInstance(oCFile, user)) + sut.supportFragmentManager.executePendingTransactions() + } + + val screenShotName = createName(testClassName + "_" + "showFileDetailSharingFragment", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showFileDetailDetailsFragment() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { sut -> + activity = sut + val fragment = ImageDetailFragment.newInstance(oCFile, user).apply { + hideMap() + } + sut.addFragment(fragment) + sut.supportFragmentManager.executePendingTransactions() + } + + val screenShotName = createName(testClassName + "_" + "showFileDetailDetailsFragment", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + @ScreenshotTest + @Suppress("MagicNumber") + fun showDetailsActivities() { + val date = GregorianCalendar().apply { + set(2005, 4, 17, 10, 35, 30) + } + + val richObjectList = ArrayList().apply { + add(RichObject("file", "abc", "text.txt", "/text.txt", "link", "tag")) + add(RichObject("file", "1", "text.txt", "/text.txt", "link", "tag")) + } + + val previewObjectList1 = ArrayList().apply { + add(PreviewObject(1, "source", "link", true, "text/plain", "view", "text.txt")) + } + + val activities = mutableListOf( + Activity( + 1, + date.time, + date.time, + "files", + "file_changed", + "user1", + "user1", + "You changed text.txt", + "", + "icon", + "link", + "files", + "1", + "/text.txt", + previewObjectList1, + RichElement("", richObjectList) + ), + Activity( + 2, + date.time, + date.time, + "comments", + "comments", + "user1", + "user1", + "admin commented", + "test2", + "icon", + "link", + "files", + "1", + "/text.txt", + emptyList(), + RichElement() + ) + ) + + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { sut -> + activity = sut + val fragment = FileDetailFragment.newInstance(oCFile, user, 0) + sut.addFragment(fragment) + sut.supportFragmentManager.executePendingTransactions() + fragment.fileDetailActivitiesFragment.populateList(activities as List?, true) + } + + val screenShotName = createName(testClassName + "_" + "showDetailsActivities", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showDetailsActivitiesNone() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { sut -> + activity = sut + val fragment = FileDetailFragment.newInstance(oCFile, user, 0) + sut.addFragment(fragment) + activity.supportFragmentManager.executePendingTransactions() + fragment.fileDetailActivitiesFragment.populateList(emptyList(), true) + } + + val screenShotName = createName(testClassName + "_" + "showDetailsActivitiesNone", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showDetailsActivitiesError() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { sut -> + activity = sut + val fragment = FileDetailFragment.newInstance(oCFile, user, 0) + sut.addFragment(fragment) + sut.supportFragmentManager.executePendingTransactions() + fragment.fileDetailActivitiesFragment.disableLoadingActivities() + fragment.fileDetailActivitiesFragment.setErrorContent( + targetContext.resources.getString(R.string.file_detail_activity_error) + ) + } + + val screenShotName = createName(testClassName + "_" + "showDetailsActivitiesError", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showDetailsSharing() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { sut -> + activity = sut + val fragment = FileDetailFragment.newInstance(oCFile, user, 1) + sut.addFragment(fragment) + sut.supportFragmentManager.executePendingTransactions() + } + + val screenShotName = createName(testClassName + "_" + "showDetailsSharing", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailSharingFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailSharingFragmentIT.kt new file mode 100644 index 000000000000..93fe006ffe70 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailSharingFragmentIT.kt @@ -0,0 +1,895 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2021 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import android.view.View +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.accessibility.AccessibilityChecks +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isChecked +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isNotChecked +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultBaseUtils.matchesCheckNames +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesViews +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.nextcloud.android.lib.resources.files.FileDownloadLimit +import com.nextcloud.test.RetryTestRule +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.OCShare.Companion.CREATE_PERMISSION_FLAG +import com.owncloud.android.lib.resources.shares.OCShare.Companion.DELETE_PERMISSION_FLAG +import com.owncloud.android.lib.resources.shares.OCShare.Companion.MAXIMUM_PERMISSIONS_FOR_FILE +import com.owncloud.android.lib.resources.shares.OCShare.Companion.MAXIMUM_PERMISSIONS_FOR_FOLDER +import com.owncloud.android.lib.resources.shares.OCShare.Companion.NO_PERMISSION +import com.owncloud.android.lib.resources.shares.OCShare.Companion.READ_PERMISSION_FLAG +import com.owncloud.android.lib.resources.shares.OCShare.Companion.SHARE_PERMISSION_FLAG +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.fragment.util.SharePermissionManager +import com.owncloud.android.utils.ScreenshotTest +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.anyOf +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.not +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@Suppress("TooManyFunctions") +class FileDetailSharingFragmentIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.fragment.FileDetailSharingFragmentIT" + + @get:Rule + val retryRule = RetryTestRule() + + lateinit var file: OCFile + lateinit var folder: OCFile + + @Before + fun before() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + file = OCFile("/test.md").apply { + remoteId = "00000001" + parentId = activity.storageManager.getFileByEncryptedRemotePath("/").fileId + permissions = OCFile.PERMISSION_CAN_RESHARE + fileDataStorageManager.saveFile(this) + } + + folder = OCFile("/test").apply { + setFolder() + parentId = activity.storageManager.getFileByEncryptedRemotePath("/").fileId + permissions = OCFile.PERMISSION_CAN_RESHARE + } + } + } + } + + @Test + @ScreenshotTest + fun listSharesFileNone() { + show(file) + } + + @Test + @ScreenshotTest + fun listSharesFileResharingNotAllowed() { + file.permissions = "" + + show(file) + } + + @Test + @ScreenshotTest + fun listSharesDownloadLimit() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + OCShare(file.decryptedRemotePath).apply { + remoteId = 1 + shareType = ShareType.PUBLIC_LINK + token = "AAAAAAAAAAAAAAA" + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 2 + shareType = ShareType.PUBLIC_LINK + token = "BBBBBBBBBBBBBBB" + fileDownloadLimit = FileDownloadLimit("BBBBBBBBBBBBBBB", 0, 0) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 3 + shareType = ShareType.PUBLIC_LINK + token = "CCCCCCCCCCCCCCC" + fileDownloadLimit = FileDownloadLimit("CCCCCCCCCCCCCCC", 10, 0) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 4 + shareType = ShareType.PUBLIC_LINK + token = "DDDDDDDDDDDDDDD" + fileDownloadLimit = FileDownloadLimit("DDDDDDDDDDDDDDD", 10, 5) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 5 + shareType = ShareType.PUBLIC_LINK + token = "FFFFFFFFFFFFFFF" + fileDownloadLimit = FileDownloadLimit("FFFFFFFFFFFFFFF", 10, 10) + activity.storageManager.saveShare(this) + } + } + + show(file) + } + } + + /** + * Use same values as {@link OCFileListFragmentStaticServerIT showSharedFiles } + */ + @Test + @ScreenshotTest + @Suppress("MagicNumber") + fun listSharesFileAllShareTypes() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + OCShare(file.decryptedRemotePath).apply { + remoteId = 1 + shareType = ShareType.USER + sharedWithDisplayName = "Admin" + permissions = MAXIMUM_PERMISSIONS_FOR_FILE + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 2 + shareType = ShareType.GROUP + sharedWithDisplayName = "Group" + permissions = MAXIMUM_PERMISSIONS_FOR_FILE + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 3 + shareType = ShareType.EMAIL + sharedWithDisplayName = "admin@nextcloud.localhost" + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 4 + shareType = ShareType.PUBLIC_LINK + label = "Customer" + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 5 + shareType = ShareType.PUBLIC_LINK + label = "Colleagues" + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 6 + shareType = ShareType.FEDERATED + sharedWithDisplayName = "admin@nextcloud.localhost" + permissions = OCShare.FEDERATED_PERMISSIONS_FOR_FILE + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 7 + shareType = ShareType.CIRCLE + sharedWithDisplayName = "Personal team" + permissions = SHARE_PERMISSION_FLAG + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 8 + shareType = ShareType.CIRCLE + sharedWithDisplayName = "Public team" + permissions = SHARE_PERMISSION_FLAG + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 9 + shareType = ShareType.CIRCLE + sharedWithDisplayName = "Closed team" + permissions = SHARE_PERMISSION_FLAG + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 10 + shareType = ShareType.CIRCLE + sharedWithDisplayName = "Secret team" + permissions = SHARE_PERMISSION_FLAG + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 11 + shareType = ShareType.ROOM + sharedWithDisplayName = "Admin" + permissions = SHARE_PERMISSION_FLAG + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + + OCShare(file.decryptedRemotePath).apply { + remoteId = 12 + shareType = ShareType.ROOM + sharedWithDisplayName = "Meeting" + permissions = SHARE_PERMISSION_FLAG + userId = getUserId(user) + activity.storageManager.saveShare(this) + } + } + + show(file) + } + } + + private fun show(file: OCFile) { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { sut -> + activity = sut + val fragment = FileDetailSharingFragment.newInstance(file, user) + sut.addFragment(fragment) + } + + val screenShotName = createName(testClassName + "_" + "show", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + // public link and email are handled the same way + // for advanced permissions + @Test + @Suppress("MagicNumber") + fun publicLinkOptionMenuFolderAdvancePermission() { + launchActivity().use { scenario -> + var sut: FileDetailSharingFragment? = null + var activity: TestActivity? = null + scenario.onActivity { testActivity -> + activity = testActivity + sut = FileDetailSharingFragment.newInstance(file, user) + activity.addFragment(sut) + activity.supportFragmentManager.executePendingTransactions() + } + + setupSecondaryFragment() + sut!!.refreshCapabilitiesFromDB() + + val publicShare = OCShare().apply { + isFolder = true + shareType = ShareType.PUBLIC_LINK + permissions = 17 + } + + activity!!.runOnUiThread { sut.showSharingMenuActionSheet(publicShare) } + + // check if items are visible + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_send_new_email)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_send_link)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_unshare)).check(matches(isDisplayed())) + + // click event + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).perform(ViewActions.click()) + + // validate view shown on screen + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.file_request_radio_button)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.share_process_hide_download_checkbox)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.share_process_set_password_switch)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.share_process_change_name_switch)).check(matches(isDisplayed())) + + // read-only + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.file_request_radio_button)).check(matches(isNotChecked())) + goBack() + + // upload and editing + publicShare.permissions = MAXIMUM_PERMISSIONS_FOR_FOLDER + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.file_request_radio_button)).check(matches(isNotChecked())) + goBack() + + // file request + publicShare.permissions = 4 + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.file_request_radio_button)).check(matches(isChecked())) + goBack() + + // password protection + publicShare.shareWith = "someValue" + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_set_password_switch)).check(matches(isChecked())) + goBack() + + publicShare.shareWith = "" + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_set_password_switch)).check(matches(isNotChecked())) + goBack() + + // hide download + publicShare.isHideFileDownload = true + publicShare.permissions = MAXIMUM_PERMISSIONS_FOR_FOLDER + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_hide_download_checkbox)).check(matches(isChecked())) + goBack() + + publicShare.isHideFileDownload = false + openAdvancedPermissions(sut, publicShare) + onView( + ViewMatchers.withId(R.id.share_process_hide_download_checkbox) + ).check(matches(isNotChecked())) + goBack() + + publicShare.expirationDate = 1582019340000 + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_set_exp_date_switch)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.share_process_select_exp_date)).check(matches(not(withText("")))) + goBack() + + publicShare.expirationDate = 0 + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_set_exp_date_switch)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.share_process_select_exp_date)).check(matches(withText(""))) + } + } + + // public link and email are handled the same way + // for send new email + @Test + @Suppress("MagicNumber") + fun publicLinkOptionMenuFolderSendNewEmail() { + launchActivity().use { scenario -> + var sut: FileDetailSharingFragment? = null + scenario.onActivity { activity -> + sut = FileDetailSharingFragment.newInstance(file, user) + activity.addFragment(sut) + sut.refreshCapabilitiesFromDB() + } + + setupSecondaryFragment() + + val publicShare = OCShare().apply { + isFolder = true + shareType = ShareType.PUBLIC_LINK + permissions = 17 + } + + verifySendNewEmail(sut!!, publicShare) + } + } + + private fun setupSecondaryFragment() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + val parentFolder = OCFile("/") + val secondary = FileDetailFragment.newInstance(file, parentFolder, user) + activity.addSecondaryFragment(secondary, FileDisplayActivity.TAG_LIST_OF_FILES) + activity.addView( + FloatingActionButton(activity).apply { + // needed for some reason + visibility = View.GONE + id = R.id.fab_main + } + ) + } + } + } + + // public link and email are handled the same way + // for advanced permissions + @Test + @Suppress("MagicNumber") + fun publicLinkOptionMenuFileAdvancePermission() { + launchActivity().use { scenario -> + var sut: FileDetailSharingFragment? = null + var activity: TestActivity? = null + scenario.onActivity { testActivity -> + activity = testActivity + sut = FileDetailSharingFragment.newInstance(file, user) + activity.addFragment(sut) + sut.refreshCapabilitiesFromDB() + } + + setupSecondaryFragment() + + val publicShare = OCShare().apply { + isFolder = false + shareType = ShareType.PUBLIC_LINK + permissions = 17 + } + activity!!.handler.post { sut!!.showSharingMenuActionSheet(publicShare) } + + // check if items are visible + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_send_new_email)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_send_link)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_unshare)).check(matches(isDisplayed())) + + // click event + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).perform(ViewActions.click()) + + // validate view shown on screen + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isDisplayed())) + onView( + ViewMatchers.withId(R.id.file_request_radio_button) + ).check(matches(not(isDisplayed()))) + onView(ViewMatchers.withId(R.id.share_process_hide_download_checkbox)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.share_process_set_password_switch)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.share_process_change_name_switch)).check(matches(isDisplayed())) + + // read-only + publicShare.permissions = 17 // from server + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isNotChecked())) + goBack() + + // editing + publicShare.permissions = MAXIMUM_PERMISSIONS_FOR_FILE // from server + openAdvancedPermissions(sut!!, publicShare) + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isChecked())) + goBack() + + // hide download + publicShare.isHideFileDownload = true + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_hide_download_checkbox)).check(matches(isChecked())) + goBack() + + publicShare.isHideFileDownload = false + openAdvancedPermissions(sut, publicShare) + onView( + ViewMatchers.withId(R.id.share_process_hide_download_checkbox) + ).check(matches(isNotChecked())) + goBack() + + // password protection + publicShare.isPasswordProtected = true + publicShare.shareWith = "someValue" + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_set_password_switch)).check(matches(isChecked())) + goBack() + + publicShare.isPasswordProtected = false + publicShare.shareWith = "" + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_set_password_switch)).check(matches(isNotChecked())) + goBack() + + // expires + publicShare.expirationDate = 1582019340 + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_set_exp_date_switch)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.share_process_select_exp_date)).check(matches(not(withText("")))) + goBack() + + publicShare.expirationDate = 0 + openAdvancedPermissions(sut, publicShare) + onView(ViewMatchers.withId(R.id.share_process_set_exp_date_switch)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.share_process_select_exp_date)).check(matches(withText(""))) + } + } + + // public link and email are handled the same way + // for send new email + @Test + @Suppress("MagicNumber") + fun publicLinkOptionMenuFileSendNewEmail() { + launchActivity().use { scenario -> + var sut: FileDetailSharingFragment? = null + + scenario.onActivity { activity -> + sut = FileDetailSharingFragment.newInstance(file, user) + activity.addFragment(sut) + sut.refreshCapabilitiesFromDB() + } + + setupSecondaryFragment() + + val publicShare = OCShare().apply { + isFolder = false + shareType = ShareType.PUBLIC_LINK + permissions = 17 + } + + verifySendNewEmail(sut!!, publicShare) + } + } + + // also applies for + // group + // conversation + // circle + // federated share + // for advanced permissions + @Test + @Suppress("MagicNumber") + fun userOptionMenuFileAdvancePermission() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + var sut: FileDetailSharingFragment? = null + scenario.onActivity { testActivity -> + activity = testActivity + sut = FileDetailSharingFragment.newInstance(file, user) + suppressFDFAccessibilityChecks() + activity.addFragment(sut) + sut.refreshCapabilitiesFromDB() + } + + setupSecondaryFragment() + + val userShare = OCShare().apply { + isFolder = false + shareType = ShareType.USER + permissions = 17 + } + + activity!!.runOnUiThread { sut!!.showSharingMenuActionSheet(userShare) } + + // check if items are visible + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_send_new_email)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_send_link)).check(matches(not(isDisplayed()))) + onView(ViewMatchers.withId(R.id.menu_share_unshare)).check(matches(isDisplayed())) + + // click event + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).perform(ViewActions.click()) + + // validate view shown on screen + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isDisplayed())) + onView( + ViewMatchers.withId(R.id.file_request_radio_button) + ).check(matches(not(isDisplayed()))) + onView( + ViewMatchers.withId(R.id.share_process_hide_download_checkbox) + ).check(matches(not(isDisplayed()))) + onView( + ViewMatchers.withId(R.id.share_process_set_password_switch) + ).check(matches(not(isDisplayed()))) + onView( + ViewMatchers.withId(R.id.share_process_change_name_switch) + ).check(matches(not(isDisplayed()))) + + // read-only + userShare.permissions = 17 // from server + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isNotChecked())) + goBack() + + // editing + userShare.permissions = MAXIMUM_PERMISSIONS_FOR_FILE // from server + openAdvancedPermissions(sut!!, userShare) + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isChecked())) + goBack() + + // set expiration date + userShare.expirationDate = 1582019340000 + openAdvancedPermissions(sut, userShare) + onView(ViewMatchers.withId(R.id.share_process_set_exp_date_switch)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.share_process_select_exp_date)).check(matches(not(withText("")))) + goBack() + + userShare.expirationDate = 0 + openAdvancedPermissions(sut, userShare) + onView(ViewMatchers.withId(R.id.share_process_set_exp_date_switch)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.share_process_select_exp_date)).check(matches(withText(""))) + } + } + + private fun suppressFDFAccessibilityChecks() { + AccessibilityChecks.enable().apply { + setSuppressingResultMatcher( + allOf( + anyOf( + matchesCheckNames(`is`("TouchTargetSizeCheck")), + matchesCheckNames(`is`("SpeakableTextPresentCheck")) + ), + anyOf( + matchesViews(ViewMatchers.withId(R.id.favorite)), + matchesViews(ViewMatchers.withId(R.id.last_modification_timestamp)) + ) + ) + ) + } + } + + // also applies for + // group + // conversation + // circle + // federated share + // for send new email + @Test + @Suppress("MagicNumber") + fun userOptionMenuFileSendNewEmail() { + launchActivity().use { scenario -> + var sut: FileDetailSharingFragment? = null + scenario.onActivity { activity -> + sut = FileDetailSharingFragment.newInstance(file, user) + activity.addFragment(sut) + sut.refreshCapabilitiesFromDB() + } + + setupSecondaryFragment() + + val userShare = OCShare().apply { + remoteId = 1001L + isFolder = false + shareType = ShareType.USER + permissions = 17 + } + + verifySendNewEmail(sut!!, userShare) + } + } + + // also applies for + // group + // conversation + // circle + // federated share + // for advanced permissions + @Test + @Suppress("MagicNumber") + fun userOptionMenuFolderAdvancePermission() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + var sut: FileDetailSharingFragment? = null + scenario.onActivity { testActivity -> + activity = testActivity + sut = FileDetailSharingFragment.newInstance(file, user) + activity.addFragment(sut) + suppressFDFAccessibilityChecks() + sut.refreshCapabilitiesFromDB() + } + + setupSecondaryFragment() + + val userShare = OCShare().apply { + isFolder = true + shareType = ShareType.USER + permissions = 17 + } + + activity!!.runOnUiThread { sut!!.showSharingMenuActionSheet(userShare) } + + // check if items are visible + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_send_new_email)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.menu_share_send_link)).check(matches(not(isDisplayed()))) + onView(ViewMatchers.withId(R.id.menu_share_unshare)).check(matches(isDisplayed())) + + // click event + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).perform(ViewActions.click()) + + // validate view shown on screen + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isDisplayed())) + onView(ViewMatchers.withId(R.id.file_request_radio_button)).check(matches(isDisplayed())) + onView( + ViewMatchers.withId(R.id.share_process_hide_download_checkbox) + ).check(matches(not(isDisplayed()))) + onView( + ViewMatchers.withId(R.id.share_process_set_password_switch) + ).check(matches(not(isDisplayed()))) + onView( + ViewMatchers.withId(R.id.share_process_change_name_switch) + ).check(matches(not(isDisplayed()))) + + // read-only + userShare.permissions = 17 // from server + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.file_request_radio_button)).check(matches(isNotChecked())) + goBack() + + // allow upload & editing + userShare.permissions = MAXIMUM_PERMISSIONS_FOR_FOLDER // from server + openAdvancedPermissions(sut!!, userShare) + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.file_request_radio_button)).check(matches(isNotChecked())) + goBack() + + // file request + userShare.permissions = 4 + openAdvancedPermissions(sut, userShare) + onView(ViewMatchers.withId(R.id.view_only_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.can_edit_radio_button)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.file_request_radio_button)).check(matches(isChecked())) + goBack() + + // set expiration date + userShare.expirationDate = 1582019340000 + openAdvancedPermissions(sut, userShare) + onView(ViewMatchers.withId(R.id.share_process_set_exp_date_switch)).check(matches(isChecked())) + onView(ViewMatchers.withId(R.id.share_process_select_exp_date)).check(matches(not(withText("")))) + goBack() + + userShare.expirationDate = 0 + openAdvancedPermissions(sut, userShare) + onView(ViewMatchers.withId(R.id.share_process_set_exp_date_switch)).check(matches(isNotChecked())) + onView(ViewMatchers.withId(R.id.share_process_select_exp_date)).check(matches(withText(""))) + } + } + + // open bottom sheet with actions + private fun openAdvancedPermissions(sut: FileDetailSharingFragment, userShare: OCShare) { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + activity.handler.post { + sut.showSharingMenuActionSheet(userShare) + } + } + + onView(ViewMatchers.withId(R.id.menu_share_advanced_permissions)).perform(ViewActions.click()) + } + } + + // remove the fragment shown + private fun goBack() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + activity.handler.post { + val processFragment = + activity.supportFragmentManager.findFragmentByTag(FileDetailsSharingProcessFragment.TAG) as + FileDetailsSharingProcessFragment + processFragment.activity?.onBackPressedDispatcher?.onBackPressed() + } + } + } + } + + // also applies for + // group + // conversation + // circle + // federated share + // for send new email + @Test + @Suppress("MagicNumber") + fun userOptionMenuFolderSendNewEmail() { + launchActivity().use { scenario -> + var sut: FileDetailSharingFragment? = null + scenario.onActivity { activity -> + sut = FileDetailSharingFragment.newInstance(file, user) + activity.addFragment(sut) + sut.refreshCapabilitiesFromDB() + } + + setupSecondaryFragment() + + val userShare = OCShare().apply { + isFolder = true + shareType = ShareType.USER + permissions = 17 + } + + verifySendNewEmail(sut!!, userShare) + } + } + + /** + * verify send new email note text + */ + private fun verifySendNewEmail(sut: FileDetailSharingFragment, userShare: OCShare) { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + activity.runOnUiThread { sut.showSharingMenuActionSheet(userShare) } + } + + // click event + onView(ViewMatchers.withId(R.id.menu_share_send_new_email)).perform(ViewActions.click()) + + // validate view shown on screen + onView(ViewMatchers.withId(R.id.note_text)).check(matches(isDisplayed())) + } + } + + @Test + fun testUploadAndEditingSharePermissions() { + val testCases = mapOf( + MAXIMUM_PERMISSIONS_FOR_FOLDER to true, + NO_PERMISSION to false, + READ_PERMISSION_FLAG to false, + CREATE_PERMISSION_FLAG to false, + DELETE_PERMISSION_FLAG to false, + SHARE_PERMISSION_FLAG to false + ) + + val share = OCShare() + for ((permission, expected) in testCases) { + share.permissions = permission + assertEquals("Failed for permission: $permission", expected, SharePermissionManager.canEdit(share)) + } + } + + @Test + fun testReadOnlySharePermissions() { + val testCases = mapOf( + READ_PERMISSION_FLAG to true, + NO_PERMISSION to false, + CREATE_PERMISSION_FLAG to false, + DELETE_PERMISSION_FLAG to false, + SHARE_PERMISSION_FLAG to false, + MAXIMUM_PERMISSIONS_FOR_FOLDER to false, + MAXIMUM_PERMISSIONS_FOR_FILE to false + ) + + val share = OCShare() + for ((permission, expected) in testCases) { + share.permissions = permission + assertEquals("Failed for permission: $permission", expected, SharePermissionManager.isViewOnly(share)) + } + } + + @Test + fun testFileRequestSharePermission() { + val testCases = mapOf( + CREATE_PERMISSION_FLAG to true, + NO_PERMISSION to false, + READ_PERMISSION_FLAG to false, + DELETE_PERMISSION_FLAG to false, + SHARE_PERMISSION_FLAG to false, + MAXIMUM_PERMISSIONS_FOR_FOLDER to false, + MAXIMUM_PERMISSIONS_FOR_FILE to false + ) + + val share = OCShare().apply { + isFolder = true + } + + for ((permission, expected) in testCases) { + share.permissions = permission + assertEquals("Failed for permission: $permission", expected, SharePermissionManager.isFileRequest(share)) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt new file mode 100644 index 000000000000..3b4ede5f274b --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt @@ -0,0 +1,228 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Philipp Hasper + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.datamodel.ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.model.ImageDimension +import com.owncloud.android.ui.adapter.GalleryRowHolder +import com.owncloud.android.utils.ScreenshotTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import java.util.Random +import org.hamcrest.Matchers.`is` as isSameView + +class GalleryFragmentIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.fragment.GalleryFragmentIT" + private val random = Random(1) + + @Before + fun registerIdlingResource() { + // initialise thumbnails cache on background thread + @Suppress("DEPRECATION") + ThumbnailsCacheManager.initDiskCacheAsync() + } + + @After + fun unregisterIdlingResource() { + ThumbnailsCacheManager.clearCache() + } + + @Test + @ScreenshotTest + fun showEmpty() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { testActivity -> + activity = testActivity + val sut = GalleryFragment() + activity.addFragment(sut) + } + + val screenShotName = createName(testClassName + "_" + "showEmpty", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showGallery() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { testActivity -> + activity = testActivity + createImage(10000001, 700, 300) + createImage(10000002, 500, 300) + createImage(10000007, 300, 400) + + val sut = GalleryFragment() + activity.addFragment(sut) + } + + val screenShotName = createName(testClassName + "_" + "showGallery", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + fun multiSelect() { + val imageCount = 100 + for (num in 1..imageCount) { + // Spread the files over multiple days to also get multiple sections + val secondsPerDay = 1L * 24 * 60 * 60 + createImage(10000000 + num * 7 * secondsPerDay, 700, 300) + } + + // Test that scrolling through the whole list is possible without a crash + launchActivity().use { scenario -> + lateinit var galleryFragment: GalleryFragment + scenario.onActivity { testActivity -> + galleryFragment = GalleryFragment() + testActivity.addFragment(galleryFragment) + } + onView(isRoot()).check(matches(isDisplayed())) + + onView(withId(R.id.list_root)) + .perform(RecyclerViewActions.scrollToLastPosition()) + .perform(RecyclerViewActions.scrollToPosition(0)) + } + + // Test selection of all entries + launchActivity().use { scenario -> + lateinit var galleryFragment: GalleryFragment + scenario.onActivity { testActivity -> + galleryFragment = GalleryFragment() + testActivity.addFragment(galleryFragment) + } + onView(isRoot()).check(matches(isDisplayed())) + + // get the RecyclerView and itemCount on the UI thread + val recyclerView = findRecyclerViewRecursively(galleryFragment.view) + ?: throw AssertionError("RecyclerView not found") + val adapterCount = recyclerView.adapter?.itemCount ?: 0 + + // Perform the view action on each adapter position (row) + for (pos in 0 until adapterCount) { + onView(isSameView(recyclerView)) + .perform(actionOnItemAtPosition(pos, longClickAllThumbnailsInRow())) + } + + val checked = galleryFragment.commonAdapter.getCheckedItems() + assertEquals(imageCount, checked.size) + } + } + + /** Recursively walk view tree to find the first RecyclerView. Runs on the same thread that calls it. */ + @Suppress("ReturnCount") + private fun findRecyclerViewRecursively(root: View?): RecyclerView? { + if (root == null) return null + if (root is RecyclerView) return root + if (root !is ViewGroup) return null + for (i in 0 until root.childCount) { + val child = root.getChildAt(i) + val found = findRecyclerViewRecursively(child) + if (found != null) return found + } + return null + } + + /** For the given row view, long-click each thumbnail inside its FrameLayouts */ + @Suppress("NestedBlockDepth") + fun longClickAllThumbnailsInRow() = object : ViewAction { + override fun getConstraints() = isDisplayed() + + override fun getDescription() = "Long-click all thumbnail ImageViews inside a GalleryRowHolder" + + override fun perform(uiController: UiController, view: View) { + if (view is ViewGroup) { + // each child of the row is a FrameLayout representing one gallery cell + for (i in 0 until view.childCount) { + val cell = view.getChildAt(i) + if (cell is FrameLayout) { + // GalleryRowHolder builds FrameLayout with children: + // 0 = shimmer, 1 = thumbnail ImageView, 2 = checkbox + val thumbnail = if (cell.childCount > 1) cell.getChildAt(1) else cell + thumbnail.performLongClick() + } + } + } + } + } + + private fun createImage(id: Long, width: Int? = null, height: Int? = null) { + val defaultSize = ThumbnailsCacheManager.getThumbnailDimension().toFloat() + val file = OCFile("/$id.png").apply { + fileId = id + remoteId = "$id" + mimeType = "image/png" + isPreviewAvailable = true + modificationTimestamp = (1658475504 + id) * 1000 + imageDimension = ImageDimension(width?.toFloat() ?: defaultSize, height?.toFloat() ?: defaultSize) + storageManager.saveFile(this) + } + + // create dummy thumbnail + val w: Int + val h: Int + if (width == null || height == null) { + if (random.nextBoolean()) { + // portrait + w = (random.nextInt(3) + 2) * 100 // 200-400 + h = (random.nextInt(5) + 4) * 100 // 400-800 + } else { + // landscape + w = (random.nextInt(5) + 4) * 100 // 400-800 + h = (random.nextInt(3) + 2) * 100 // 200-400 + } + } else { + w = width + h = height + } + + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + Canvas(bitmap).apply { + drawRGB(random.nextInt(256), random.nextInt(256), random.nextInt(256)) + drawCircle(w / 2f, h / 2f, w.coerceAtMost(h) / 2f, Paint().apply { color = Color.BLACK }) + } + ThumbnailsCacheManager.addBitmapToCache(PREFIX_RESIZED_IMAGE + file.remoteId, bitmap) + + assertNotNull(ThumbnailsCacheManager.getBitmapFromDiskCache(PREFIX_RESIZED_IMAGE + file.remoteId)) + + Log_OC.d("Gallery_thumbnail", "created $id with ${bitmap.width} x ${bitmap.height}") + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/GroupfolderListFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/GroupfolderListFragmentIT.kt new file mode 100644 index 000000000000..1c5703e5bd46 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/GroupfolderListFragmentIT.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.android.lib.resources.groupfolders.Groupfolder +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class GroupfolderListFragmentIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.fragment.GroupfolderListFragmentIT" + + @Test + @ScreenshotTest + fun showGroupfolder() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { testActivity -> + activity = testActivity + val sut = GroupfolderListFragment() + activity.addFragment(sut) + activity.supportFragmentManager.executePendingTransactions() + + sut.setAdapter(null) + sut.setData( + mapOf( + Pair("2", Groupfolder(2, "/subfolder/group")) + ) + ) + } + + val screenShotName = createName(testClassName + "_" + "showGroupfolder", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } + + @Test + @ScreenshotTest + fun showGroupfolders() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { testActivity -> + activity = testActivity + val sut = GroupfolderListFragment() + activity.addFragment(sut) + activity.supportFragmentManager.executePendingTransactions() + sut.setAdapter(null) + sut.setData( + mapOf( + Pair("1", Groupfolder(1, "/test/")), + Pair("2", Groupfolder(2, "/subfolder/group")) + ) + ) + } + + val screenShotName = createName(testClassName + "_" + "showGroupfolders", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/NotificationsFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/NotificationsFragmentIT.kt new file mode 100644 index 000000000000..6959dcdc4d85 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/NotificationsFragmentIT.kt @@ -0,0 +1,172 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.GrantStoragePermissionRule.Companion.grant +import com.owncloud.android.AbstractIT +import com.owncloud.android.lib.resources.notifications.models.Action +import com.owncloud.android.lib.resources.notifications.models.Notification +import com.owncloud.android.ui.fragment.notifications.NotificationsFragment +import com.owncloud.android.ui.fragment.notifications.model.NotificationsUIState +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.ui.navigation.NavigatorScreen +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import java.util.Date +import java.util.GregorianCalendar + +class NotificationsFragmentIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.fragment.notifications.NotificationsFragmentIT" + + @get:Rule + var storagePermissionRules: TestRule = grant() + + private fun buildDate(): Date = GregorianCalendar().apply { + set(2005, 4, 17, 10, 35, 30) + }.time + + private fun buildNotificationNoActions(): Notification = Notification( + 1, + "files", + "user", + buildDate(), + "objectType", + "objectId", + "App recommendation: Tasks", + "SubjectRich", + HashMap(), + "Sync tasks from various devices with your Nextcloud and edit them online.", + "MessageRich", + HashMap(), + "link", + "icon", + ArrayList() + ) + + private fun buildNotificationTwoActions(): Notification { + val actions = ArrayList().apply { + add(Action("Send usage", "link", "url", true)) + add(Action("Not now", "link", "url", false)) + } + + return Notification( + 2, + "files", + "user", + buildDate(), + "objectType", + "objectId", + "Help improve Nextcloud", + "SubjectRich", + HashMap(), + "Do you want to help us to improve Nextcloud by providing some anonymize data about your setup and usage?", + "MessageRich", + HashMap(), + "link", + "icon", + actions + ) + } + + private fun buildNotificationManyActions(): Notification { + val actions = ArrayList().apply { + add(Action("Send usage", "link", "url", true)) + add(Action("Not now", "link", "url", false)) + add(Action("Third action", "link", "url", false)) + add(Action("Delay", "link", "url", false)) + } + + return Notification( + 3, + "files", + "user", + buildDate(), + "objectType", + "objectId", + "Help improve Nextcloud", + "SubjectRich", + HashMap(), + "Do you want to help us to improve Nextcloud by providing some anonymize data about your setup" + + " and usage?", + "MessageRich", + HashMap(), + "link", + "icon", + actions + ) + } + + fun buildMockNotifications(): ArrayList = ArrayList().apply { + add(buildNotificationNoActions()) + add(buildNotificationTwoActions()) + add(buildNotificationManyActions()) + } + + @Suppress("ReturnCount") + private fun findFragment(sut: NavigatorActivity): NotificationsFragment? { + val allFragments = sut.supportFragmentManager.fragments + for (f in allFragments) { + if (f is NotificationsFragment) return f + val child = f.childFragmentManager.fragments.filterIsInstance().firstOrNull() + if (child != null) return child + } + return null + } + + private fun launchFragment(name: String, block: NotificationsFragment.() -> Unit) { + val intent = NavigatorActivity.intent(targetContext, NavigatorScreen.Notifications) + .putExtra(NotificationsFragment.EXTRA_INIT_FOR_TESTING, true) + + ActivityScenario.launch(intent).use { scenario -> + onView(isRoot()).check(matches(isDisplayed())) + + scenario.onActivity { sut -> + val fragment = findFragment(sut) + ?: throw IllegalStateException("NotificationsFragment not found in NavigatorActivity!") + fragment.block() + } + + onView(isRoot()).check(matches(isDisplayed())) + + val screenShotName = createName(testClassName + "_" + name, "") + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } + + @Test + @ScreenshotTest + fun empty() { + launchFragment("empty") { initForTesting(NotificationsUIState.Empty) } + } + + @Test + @ScreenshotTest + fun showNotifications() { + launchFragment("showNotifications") { + initForTesting(NotificationsUIState.Loaded(buildMockNotifications())) + } + } + + @Test + @ScreenshotTest + fun error() { + launchFragment("error") { + initForTesting(NotificationsUIState.Error("Error! Please try again later!")) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt new file mode 100644 index 000000000000..59bf0b97f64f --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt @@ -0,0 +1,462 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.nextcloud.test.GrantStoragePermissionRule.Companion.grant +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.lib.resources.shares.ShareeUser +import com.owncloud.android.lib.resources.tags.Tag +import com.owncloud.android.ui.activity.FolderPickerActivity +import com.owncloud.android.utils.EspressoIdlingResource +import com.owncloud.android.utils.MimeType +import com.owncloud.android.utils.ScreenshotTest +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.not +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule + +class OCFileListFragmentStaticServerIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.fragment.OCFileListFragmentStaticServerIT" + + @get:Rule + var storagePermissionRule: TestRule = grant() + + @Before + fun initIntentRecording() { + Intents.init() + } + + @Test + @ScreenshotTest + @Suppress("MagicNumber") + fun showFiles() { + launchActivity().use { scenario -> + var sut: TestActivity? = null + scenario.onActivity { testActivity -> + sut = testActivity + OCFile("/1.png").apply { + remoteId = "00000001" + mimeType = "image/png" + fileLength = 1024000 + modificationTimestamp = 1188206955000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + sut.storageManager.saveFile(this) + } + + OCFile("/image.png").apply { + remoteId = "00000002" + mimeType = "image/png" + isPreviewAvailable = false + fileLength = 3072000 + modificationTimestamp = 746443755000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + tags = listOf(Tag("", "Top secret", null)) + sut.storageManager.saveFile(this) + } + + OCFile("/live photo.png").apply { + remoteId = "00000003" + mimeType = "image/png" + isPreviewAvailable = false + fileLength = 3072000 + modificationTimestamp = 746443755000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + setLivePhoto("/video.mov") + sut.storageManager.saveFile(this) + } + + OCFile("/video.mp4").apply { + remoteId = "00000004" + mimeType = "video/mp4" + isPreviewAvailable = false + fileLength = 12092000 + modificationTimestamp = 746143952000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + tags = listOf(Tag("", "Confidential", null), Tag("", "+5", null)) + sut.storageManager.saveFile(this) + } + + sut.addFragment(OCFileListFragment()) + sut.supportFragmentManager.executePendingTransactions() + val fragment = (sut.fragment as OCFileListFragment) + val root = sut.storageManager.getFileByEncryptedRemotePath("/") + fragment.listDirectory(root, false) + } + + val screenShotName = createName(testClassName + "_" + "showFiles", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + /** + * Use same values as {@link FileDetailSharingFragmentIT listSharesFileAllShareTypes } + */ + @Test + @ScreenshotTest + fun showSharedFiles() { + launchActivity().use { scenario -> + var sut: TestActivity? = null + scenario.onActivity { testActivity -> + sut = testActivity + val fragment = OCFileListFragment() + + OCFile("/sharedToUser.jpg").apply { + remoteId = "00000001" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedWithSharee = true + sharees = listOf(ShareeUser("Admin", "Server Admin", ShareType.USER)) + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/sharedToGroup.jpg").apply { + remoteId = "00000002" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedWithSharee = true + sharees = listOf(ShareeUser("group", "Group", ShareType.GROUP)) + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/sharedToEmail.jpg").apply { + remoteId = "00000003" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedWithSharee = true + sharees = + listOf( + ShareeUser("admin@nextcloud.localhost", "admin@nextcloud.localhost", ShareType.EMAIL) + ) + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/publicLink.jpg").apply { + remoteId = "00000004" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedViaLink = true + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/sharedToFederatedUser.jpg").apply { + remoteId = "00000005" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedWithSharee = true + sharees = listOf( + ShareeUser( + "admin@remote.nextcloud.com", + "admin@remote.nextcloud.com (remote)", + ShareType.FEDERATED + ) + ) + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/sharedToPersonalCircle.jpg").apply { + remoteId = "00000006" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedWithSharee = true + sharees = listOf(ShareeUser("circle", "Circle (Personal circle)", ShareType.CIRCLE)) + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/sharedToUserRoom.jpg").apply { + remoteId = "00000007" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedWithSharee = true + sharees = listOf(ShareeUser("Conversation", "Admin", ShareType.ROOM)) + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/sharedToGroupRoom.jpg").apply { + remoteId = "00000008" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedWithSharee = true + sharees = listOf(ShareeUser("Conversation", "Meeting", ShareType.ROOM)) + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/sharedToUsers.jpg").apply { + remoteId = "00000009" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + isSharedWithSharee = true + sharees = listOf( + ShareeUser("Admin", "Server Admin", ShareType.USER), + ShareeUser("User", "User", ShareType.USER), + ShareeUser("Christine", "Christine Scott", ShareType.USER) + ) + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/notShared.jpg").apply { + remoteId = "000000010" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + OCFile("/Foo%e2%80%aedm.exe").apply { + remoteId = "000000011" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + + sut.addFragment(fragment) + sut.supportFragmentManager.executePendingTransactions() + val root = sut.storageManager.getFileByEncryptedRemotePath("/") + fragment.listDirectory(root, false) + fragment.adapter.setShowShareAvatar(true) + } + + val screenShotName = createName(testClassName + "_" + "showSharedFiles", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + /** + * Use same values as {@link FileDetailSharingFragmentIT listSharesFileAllShareTypes } + */ + @Test + @ScreenshotTest + fun showFolderTypes() { + launchActivity().use { scenario -> + var sut: TestActivity? = null + scenario.onActivity { testActivity -> + sut = testActivity + val fragment = OCFileListFragment() + + OCFile("/normal/").apply { + remoteId = "00000001" + mimeType = MimeType.DIRECTORY + modificationTimestamp = 1624003571000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + sut.storageManager.saveFile(this) + } + + OCFile("/sharedViaLink/").apply { + remoteId = "00000002" + mimeType = MimeType.DIRECTORY + isSharedViaLink = true + modificationTimestamp = 1619003571000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + sut.storageManager.saveFile(this) + } + + OCFile("/share/").apply { + remoteId = "00000003" + mimeType = MimeType.DIRECTORY + isSharedWithSharee = true + modificationTimestamp = 1619303571000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + sut.storageManager.saveFile(this) + } + + OCFile("/groupFolder/").apply { + remoteId = "00000004" + mimeType = MimeType.DIRECTORY + modificationTimestamp = 1615003571000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + permissions += "M" + sut.storageManager.saveFile(this) + } + + OCFile("/encrypted/").apply { + remoteId = "00000005" + mimeType = MimeType.DIRECTORY + isEncrypted = true + decryptedRemotePath = "/encrypted/" + modificationTimestamp = 1614003571000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + sut.storageManager.saveFile(this) + } + + OCFile("/locked/").apply { + remoteId = "00000006" + mimeType = MimeType.DIRECTORY + isLocked = true + decryptedRemotePath = "/locked/" + modificationTimestamp = 1613003571000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + sut.storageManager.saveFile(this) + } + + OCFile("/offlineOperation/").apply { + mimeType = MimeType.DIRECTORY + decryptedRemotePath = "/offlineOperation/" + modificationTimestamp = System.currentTimeMillis() + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + sut.storageManager.saveFile(this) + } + + sut.addFragment(fragment) + sut.supportFragmentManager.executePendingTransactions() + val root = sut.storageManager.getFileByEncryptedRemotePath("/") + fragment.listDirectory(root, false) + fragment.adapter.setShowShareAvatar(true) + } + + val screenShotName = createName(testClassName + "_" + "showFolderTypes", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + @ScreenshotTest + @Suppress("MagicNumber") + fun showRichWorkspace() { + launchActivity().use { scenario -> + var sut: TestActivity? = null + scenario.onActivity { testActivity -> + sut = testActivity + val fragment = OCFileListFragment() + + val folder = OCFile("/test/") + folder.setFolder() + sut.storageManager.saveFile(folder) + + val imageFile = OCFile("/test/image.png").apply { + remoteId = "00000001" + mimeType = "image/png" + fileLength = 1024000 + modificationTimestamp = 1188206955000 + parentId = sut.storageManager.getFileByEncryptedRemotePath("/test/").fileId + storagePath = getFile("java.md").absolutePath + } + + sut.storageManager.saveFile(imageFile) + + sut.addFragment(fragment) + sut.supportFragmentManager.executePendingTransactions() + val testFolder: OCFile = sut.storageManager.getFileByEncryptedRemotePath("/test/") + testFolder.richWorkspace = getFile("java.md").readText() + fragment.listDirectory(testFolder, false) + } + + val screenShotName = createName(testClassName + "_" + "showRichWorkspace", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + fun shouldShowHeader() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + val sut = OCFileListFragment() + + scenario.onActivity { testActivity -> + activity = testActivity + val folder = OCFile("/test/").apply { + remoteId = "000001" + setFolder() + } + activity.storageManager.saveFile(folder) + activity.addFragment(sut) + activity.supportFragmentManager.executePendingTransactions() + } + + val testFolder: OCFile = activity!!.storageManager.getFileByEncryptedRemotePath("/test/") + + // richWorkspace is not set + Assert.assertFalse(sut.adapter.shouldShowHeader()) + + testFolder.richWorkspace = " " + activity.storageManager.saveFile(testFolder) + sut.adapter.swapDirectory(user, testFolder, activity.storageManager, false, "") + + Assert.assertFalse(sut.adapter.shouldShowHeader()) + + testFolder.richWorkspace = null + activity.storageManager.saveFile(testFolder) + sut.adapter.swapDirectory(user, testFolder, activity.storageManager, false, "") + Assert.assertFalse(sut.adapter.shouldShowHeader()) + + EspressoIdlingResource.increment() + testFolder.richWorkspace = "1" + activity.storageManager.saveFile(testFolder) + sut.adapter.setCurrentDirectory(testFolder) + + Assert.assertTrue(sut.adapter.shouldShowHeader()) + } + } + + @Test + fun shouldStartMoveInParentFolder() { + launchActivity().use { scenario -> + val fragment = OCFileListFragment() + var testFolder: OCFile? = null + + scenario.onActivity { activity -> + testFolder = OCFile("/folder/").apply { + setFolder() + } + activity.storageManager.saveNewFile(testFolder) + + val testFile = OCFile("${testFolder.remotePath}myImage.png").apply { + parentId = testFolder.fileId + activity.storageManager.saveNewFile(this) + } + + activity.addFragment(fragment) + activity.supportFragmentManager.executePendingTransactions() + + fragment.listDirectory(testFolder, false) + fragment.onFileActionChosen(R.id.action_move_or_copy, setOf(testFile)) + } + // Check that the FolderPickerActivity was opened + intended(hasComponent(FolderPickerActivity::class.java.canonicalName)) + + // Check that the Action Bar shows the current folder name as title + onView( + allOf( + isDescendantOfA(withId(R.id.toolbar)), + withText(testFolder!!.fileName) + ) + ).check(matches(isDisplayed())) + + // Test the button's enabled status. "Move" should not be enabled, but the rest should. + onView(allOf(withId(R.id.folder_picker_btn_cancel), isDisplayed())) + .check(matches(isEnabled())) + onView(allOf(withId(R.id.folder_picker_btn_copy), isDisplayed())) + .check(matches(isEnabled())) + onView(allOf(withId(R.id.folder_picker_btn_move), isDisplayed())) + .check(matches(not(isEnabled()))) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/SharedListFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/SharedListFragmentIT.kt new file mode 100644 index 000000000000..ace51a654c22 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/SharedListFragmentIT.kt @@ -0,0 +1,186 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import android.view.View +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.GrantStoragePermissionRule.Companion.grant +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.ui.adapter.OCShareToOCFileConverter +import com.owncloud.android.utils.EspressoIdlingResource +import com.owncloud.android.utils.ScreenshotTest +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule + +internal class SharedListFragmentIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.fragment.SharedListFragmentIT" + + @Before + fun registerIdlingResource() { + IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource) + } + + @After + fun unregisterIdlingResource() { + IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource) + } + + @get:Rule + var storagePermissionRule: TestRule = grant() + + @Test + @ScreenshotTest + fun showSharedFiles() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + onIdleSync { + EspressoIdlingResource.increment() + + val fragment = SharedListFragment() + + val file = OCFile("/shared to admin.png").apply { + remoteId = "00000001" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + mimeType = "image/png" + fileLength = 1024000 + modificationTimestamp = 1188206955 + permissions = OCFile.PERMISSION_CAN_RESHARE + sut.storageManager.saveFile(this) + } + + val file1 = OCFile("/shared to group.png").apply { + remoteId = "00000001" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + mimeType = "image/png" + fileLength = 1024000 + modificationTimestamp = 1188206955 + permissions = OCFile.PERMISSION_CAN_RESHARE + sut.storageManager.saveFile(this) + } + + val file2 = OCFile("/shared via public link.png").apply { + remoteId = "00000001" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + mimeType = "image/png" + fileLength = 1024000 + modificationTimestamp = 1188206955 + permissions = OCFile.PERMISSION_CAN_RESHARE + sut.storageManager.saveFile(this) + } + + val file3 = OCFile("/shared to personal circle.png").apply { + remoteId = "00000001" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + mimeType = "image/png" + fileLength = 1024000 + modificationTimestamp = 1188206955 + permissions = OCFile.PERMISSION_CAN_RESHARE + sut.storageManager.saveFile(this) + } + + val file4 = OCFile("/shared to talk.png").apply { + remoteId = "00000001" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + mimeType = "image/png" + fileLength = 1024000 + modificationTimestamp = 1188206955 + permissions = OCFile.PERMISSION_CAN_RESHARE + sut.storageManager.saveFile(this) + } + + val shares = listOf( + OCShare(file.decryptedRemotePath).apply { + remoteId = 1 + shareType = ShareType.USER + sharedWithDisplayName = "Admin" + permissions = OCShare.MAXIMUM_PERMISSIONS_FOR_FILE + userId = getUserId(user) + sharedDate = 1188206955 + mimetype = "image/png" + sut.storageManager.saveShare(this) + }, + + OCShare(file1.decryptedRemotePath).apply { + remoteId = 2 + shareType = ShareType.GROUP + sharedWithDisplayName = "Group" + permissions = OCShare.MAXIMUM_PERMISSIONS_FOR_FILE + userId = getUserId(user) + sharedDate = 1188206955 + mimetype = "image/png" + sut.storageManager.saveShare(this) + }, + + OCShare(file2.decryptedRemotePath).apply { + remoteId = 3 + shareType = ShareType.PUBLIC_LINK + label = "Customer" + sharedDate = 1188206955 + mimetype = "image/png" + sut.storageManager.saveShare(this) + }, + + OCShare(file3.decryptedRemotePath).apply { + remoteId = 4 + shareType = ShareType.CIRCLE + sharedWithDisplayName = "Personal circle" + permissions = OCShare.SHARE_PERMISSION_FLAG + userId = getUserId(user) + sharedDate = 1188206955 + mimetype = "image/png" + sut.storageManager.saveShare(this) + }, + + OCShare(file4.decryptedRemotePath).apply { + remoteId = 11 + shareType = ShareType.ROOM + sharedWithDisplayName = "Admin" + permissions = OCShare.SHARE_PERMISSION_FLAG + userId = getUserId(user) + sharedDate = 1188206955 + mimetype = "image/png" + sut.storageManager.saveShare(this) + } + ) + + sut.addFragment(fragment) + + fragment.isLoading = false + fragment.mEmptyListContainer?.visibility = View.GONE + + val newList = runBlocking { + OCShareToOCFileConverter + .parseAndSaveShares(listOf(), shares, storageManager, user.accountName) + } + fragment.adapter.run { + prepareForSearchData(storageManager, SearchType.SHARED_FILTER) + updateAdapter(newList, null) + } + EspressoIdlingResource.decrement() + + val screenShotName = createName(testClassName + "_" + "showSharedFiles", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFakeRepository.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFakeRepository.kt new file mode 100644 index 000000000000..6173181cb10e --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFakeRepository.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import com.owncloud.android.lib.common.SearchResult +import com.owncloud.android.lib.common.SearchResultEntry +import com.owncloud.android.ui.unifiedsearch.IUnifiedSearchRepository +import com.owncloud.android.ui.unifiedsearch.ProviderID +import com.owncloud.android.ui.unifiedsearch.UnifiedSearchResult + +class UnifiedSearchFakeRepository : IUnifiedSearchRepository { + + override fun queryAll( + query: String, + onResult: (UnifiedSearchResult) -> Unit, + onError: (Throwable) -> Unit, + onFinished: (Boolean) -> Unit + ) { + val result = UnifiedSearchResult( + provider = "files", + success = true, + result = SearchResult( + "files", + false, + listOf( + SearchResultEntry( + "thumbnailUrl", + "Test", + "in Files", + "http://localhost/nc/index.php/apps/files/?dir=/Files&scrollto=Test", + "icon", + false + ), + SearchResultEntry( + "thumbnailUrl", + "Test1", + "in Folder", + "http://localhost/nc/index.php/apps/files/?dir=/folder&scrollto=test1.txt", + "icon", + false + ) + ) + ) + + ) + onResult(result) + onFinished(true) + } + + override fun queryProvider( + query: String, + provider: ProviderID, + cursor: Int?, + onResult: (UnifiedSearchResult) -> Unit, + onError: (Throwable) -> Unit, + onFinished: (Boolean) -> Unit + ) { + val result = UnifiedSearchResult( + provider = provider, + success = true, + result = SearchResult( + provider, + false, + listOf( + SearchResultEntry( + "thumbnailUrl", + "Test", + "in Files", + "http://localhost/nc/index.php/apps/files/?dir=/Files&scrollto=Test", + "icon", + false + ), + SearchResultEntry( + "thumbnailUrl", + "Test1", + "in Folder", + "http://localhost/nc/index.php/apps/files/?dir=/folder&scrollto=test1.txt", + "icon", + false + ) + ) + ) + + ) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFragmentIT.kt new file mode 100644 index 000000000000..d09b7fef3272 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFragmentIT.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.fragment + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.SearchResultEntry +import com.owncloud.android.ui.unifiedsearch.UnifiedSearchSection +import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel +import org.junit.Test +import java.io.File + +class UnifiedSearchFragmentIT : AbstractIT() { + + @Test + fun showSearchResult() { + launchActivity().use { scenario -> + + scenario.onActivity { activity -> + val sut = UnifiedSearchFragment.newInstance(null, null, "/") + activity.addFragment(sut) + activity.supportFragmentManager.executePendingTransactions() + sut.onSearchResultChanged( + listOf( + UnifiedSearchSection( + providerID = "files", + name = "Files", + entries = listOf( + SearchResultEntry( + "thumbnailUrl", + "Test", + "in Files", + "http://localhost/nc/index.php/apps/files/?dir=/Files&scrollto=Test", + "icon", + false + ) + ), + hasMoreResults = false + ) + ) + ) + } + + onView(isRoot()).check(matches(isDisplayed())) + } + } + + @Test + fun search() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + val sut = UnifiedSearchFragment.newInstance(null, null, "/") + val testViewModel = UnifiedSearchViewModel(activity.application) + testViewModel.setConnectivityService(activity.connectivityServiceMock) + val localRepository = UnifiedSearchFakeRepository() + testViewModel.setRepository(localRepository) + val ocFile = OCFile("/folder/test1.txt").apply { + storagePath = "/sdcard/1.txt" + storageManager.saveFile(this) + } + + File(ocFile.storagePath).createNewFile() + activity.addFragment(sut) + activity.supportFragmentManager.executePendingTransactions() + sut.setViewModel(testViewModel) + sut.vm.setQuery("test") + sut.vm.initialQuery() + } + + onView(isRoot()).check(matches(isDisplayed())) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/helpers/FileOperationsHelperIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/helpers/FileOperationsHelperIT.kt new file mode 100644 index 000000000000..bee57897f0f6 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/helpers/FileOperationsHelperIT.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.helpers + +import com.owncloud.android.MainApp +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertTrue +import org.junit.Test + +class FileOperationsHelperIT { + + @Test + fun testNull() { + MainApp.setStoragePath(null) + assertEquals(-1L, FileOperationsHelper.getAvailableSpaceOnDevice()) + } + + @Test + fun testNonExistingPath() { + MainApp.setStoragePath("/123/123") + assertEquals(-1L, FileOperationsHelper.getAvailableSpaceOnDevice()) + } + + @Test + fun testExistingPath() { + MainApp.setStoragePath("/sdcard/") + assertTrue(FileOperationsHelper.getAvailableSpaceOnDevice() > 0L) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/helpers/UriUploaderIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/helpers/UriUploaderIT.kt new file mode 100644 index 000000000000..bd776c1d8666 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/helpers/UriUploaderIT.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.helpers + +import android.net.Uri +import androidx.test.core.app.launchActivity +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import org.junit.Assert +import org.junit.Test + +class UriUploaderIT : AbstractIT() { + + @Test + fun testUploadPrivatePathSharedPreferences() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + val packageName = activity.packageName + val path = "file:///data/data/$packageName/shared_prefs/com.nextcloud.client_preferences.xml" + testPrivatePath(activity, path) + } + } + } + + @Test + fun testUploadPrivatePathUserFile() { + launchActivity().use { scenario -> + scenario.onActivity { activity -> + val packageName = activity.packageName + val path = "file:///storage/emulated/0/Android/media/$packageName/nextcloud/test/welcome.txt" + testPrivatePath(activity, path) + } + } + } + + private fun testPrivatePath(activity: TestActivity, path: String) { + val sut = UriUploader( + activity, + listOf(Uri.parse(path)), + "", + activity.user.orElseThrow(::RuntimeException), + FileUploadWorker.LOCAL_BEHAVIOUR_MOVE, + false, + null + ) + val uploadResult = sut.uploadUris() + Assert.assertEquals( + "Wrong result code", + UriUploader.UriUploaderResultCode.ERROR_SENSITIVE_PATH, + uploadResult + ) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewBitmapScreenshotIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewBitmapScreenshotIT.kt new file mode 100644 index 000000000000..ceda14fa98da --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewBitmapScreenshotIT.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.preview + +import android.content.Intent +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.owncloud.android.AbstractIT +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class PreviewBitmapScreenshotIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.preview.PreviewBitmapScreenshotIT" + + companion object { + private const val PNG_FILE_ASSET = "imageFile.png" + } + + @Test + @ScreenshotTest + fun showBitmap() { + val pngFile = getFile(PNG_FILE_ASSET) + val intent = Intent(targetContext, PreviewBitmapActivity::class.java).putExtra( + PreviewBitmapActivity.EXTRA_BITMAP_PATH, + pngFile.absolutePath + ) + + launchActivity(intent).use { scenario -> + val screenShotName = createName(testClassName + "_" + "showBitmap", "") + onView(isRoot()).check(matches(isDisplayed())) + scenario.onActivity { sut -> + screenshotViaName(sut, screenShotName) + } + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt new file mode 100644 index 000000000000..3ce7a9333b6d --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt @@ -0,0 +1,228 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.preview + +import androidx.appcompat.widget.ActionBarContainer +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.base.DefaultFailureHandler +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.nextcloud.test.ConnectivityServiceOfflineMock +import com.nextcloud.test.FileRemovedIdlingResource +import com.nextcloud.test.LoopFailureHandler +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation +import org.hamcrest.Matchers.allOf +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import java.io.File + +class PreviewImageActivityIT : AbstractOnServerIT() { + companion object { + private const val REMOTE_FOLDER: String = "/PreviewImageActivityIT/" + } + + var fileRemovedIdlingResource = FileRemovedIdlingResource(storageManager) + + @Suppress("SameParameterValue") + private fun createLocalMockedImageFiles(count: Int): List { + val srcPngFile = getFile("imageFile.png") + return (0 until count).map { i -> + val pngFile = File(srcPngFile.parent ?: ".", "image$i.png") + srcPngFile.copyTo(pngFile, overwrite = true) + + OCFile("/${pngFile.name}").apply { + storagePath = pngFile.absolutePath + mimeType = "image/png" + modificationTimestamp = 1000000 + permissions = "D" // OCFile.PERMISSION_CAN_DELETE_OR_LEAVE_SHARE. Required for deletion button to show + }.also { + storageManager.saveNewFile(it) + } + } + } + + /** + * Create image files and upload them to the connected server. + * + * This function relies on the images not existing beforehand, as AbstractOnServerIT#deleteAllFilesOnServer() + * should clean up. If it does fail, likely because that clean up didn't work and there are leftovers from + * a previous run + * @param count Number of files to create + * @param folder Parent folder to which to upload. Must start and end with a slash + */ + private fun createAndUploadImageFiles(count: Int, folder: String = REMOTE_FOLDER): List { + val srcPngFile = getFile("imageFile.png") + return (0 until count).map { i -> + val pngFile = File(srcPngFile.parent ?: ".", "image$i.png") + srcPngFile.copyTo(pngFile, overwrite = true) + + val ocUpload = OCUpload( + pngFile.absolutePath, + folder + pngFile.name, + account.name + ).apply { + nameCollisionPolicy = NameCollisionPolicy.OVERWRITE + } + uploadOCUpload(ocUpload) + + fileDataStorageManager.getFileByDecryptedRemotePath(folder + pngFile.name)!! + } + } + + private fun veryImageThenDelete(testFile: OCFile) { + Espresso.setFailureHandler( + LoopFailureHandler(targetContext, "Test failed with image file ${testFile.fileName}") + ) + + assertTrue(testFile.exists()) + assertTrue(testFile.fileExists()) + + onView(withId(R.id.image)) + .check(matches(isDisplayed())) + + // Check that the Action Bar shows the file name as title + onView( + allOf( + isDescendantOfA(isAssignableFrom(ActionBarContainer::class.java)), + withText(testFile.fileName) + ) + ).check(matches(isDisplayed())) + + // Open the Action Bar's overflow menu. + // The official way would be: + // openActionBarOverflowOrOptionsMenu(targetContext) + // But this doesn't find the view. Presumably because Espresso.OVERFLOW_BUTTON_MATCHER looks for the description + // "More options", whereas it actually says "More menu". + // selecting by this would also work: + // onView(withContentDescription("More menu")).perform(ViewActions.click()) + // For now, we identify it by the ID we know it to be + onView(withId(R.id.custom_menu_placeholder_item)).perform(ViewActions.click()) + + // Click the "Remove" button + onView(withText(R.string.common_remove)).perform(ViewActions.click()) + + // Check confirmation dialog and then confirm the deletion by clicking the main button of the dialog + val expectedText = targetContext.getString(R.string.confirmation_remove_file_alert, testFile.fileName) + onView(withId(android.R.id.message)) + .inRoot(isDialog()) + .check(matches(withText(expectedText))) + + onView(withId(android.R.id.button1)) + .inRoot(isDialog()) + .check(matches(withText(R.string.file_delete))) + .perform(ViewActions.click()) + + // Register the idling resource to wait for successful deletion + fileRemovedIdlingResource.setFile(testFile) + + // Wait for idle, then verify that the file is gone. Somehow waitForIdleSync() doesn't work and we need onIdle() + Espresso.onIdle() + assertFalse("test file still exists: ${testFile.fileName}", testFile.exists()) + + Espresso.setFailureHandler(DefaultFailureHandler(targetContext)) + } + + private fun executeDeletionTestScenario( + localOnly: Boolean, + offline: Boolean, + fileListTransformation: (List) -> List + ) { + val imageCount = 5 + val testFiles = if (localOnly) { + createLocalMockedImageFiles( + imageCount + ) + } else { + createAndUploadImageFiles(imageCount) + } + val expectedFileOrder = fileListTransformation(testFiles) + + val intent = PreviewImageActivity.previewFileIntent(targetContext, user, expectedFileOrder.first()) + launchActivity(intent).use { scenario -> + if (offline) { + scenario.onActivity { activity -> + activity.connectivityService = ConnectivityServiceOfflineMock() + } + } + onView(isRoot()).check(matches(isDisplayed())) + + for (testFile in expectedFileOrder) { + veryImageThenDelete(testFile) + assertTrue( + "Test file still exists on the server: ${testFile.remotePath}", + ExistenceCheckRemoteOperation(testFile.remotePath, true).execute(client).isSuccess + ) + } + } + } + + private fun testDeleteFromSlideshow_impl(localOnly: Boolean, offline: Boolean) { + // Case 1: start at first image + executeDeletionTestScenario(localOnly, offline) { list -> list } + // Case 2: start at last image (reversed) + executeDeletionTestScenario(localOnly, offline) { list -> list.reversed() } + // Case 3: Start in the middle. From middle to the end, then backwards through remaining files of the first half + executeDeletionTestScenario(localOnly, offline) { list -> + list.subList(list.size / 2, list.size) + list.subList(0, list.size / 2).reversed() + } + } + + @Before + fun bringUp() { + IdlingRegistry.getInstance().register(fileRemovedIdlingResource) + } + + @After + fun tearDown() { + IdlingRegistry.getInstance().unregister(fileRemovedIdlingResource) + } + + @Test + fun deleteFromSlideshow_localOnly_online() { + testDeleteFromSlideshow_impl(localOnly = true, offline = false) + } + + @Test + fun deleteFromSlideshow_localOnly_offline() { + testDeleteFromSlideshow_impl(localOnly = true, offline = true) + } + + @Test + fun deleteFromSlideshow_remote_online() { + testDeleteFromSlideshow_impl(localOnly = false, offline = false) + } + + @Test + @Ignore( + "Offline deletion is following a different UX and it is also brittle: Deletion might happen 10 minutes later" + ) + fun deleteFromSlideshow_remote_offline() { + // Note: the offline mock doesn't actually do what it is supposed to. The OfflineOperationsWorker uses its + // own connectivityService, which is online, and may still execute the server deletion. + // You'll need to address this, should you activate that test. Otherwise it might not catch all error cases + testDeleteFromSlideshow_impl(localOnly = false, offline = true) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewTextFileFragmentTest.kt b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewTextFileFragmentTest.kt new file mode 100644 index 000000000000..93ac4bef85af --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewTextFileFragmentTest.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.preview + +import android.Manifest +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.rule.GrantPermissionRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +class PreviewTextFileFragmentTest : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.preview.PreviewTextFileFragmentTest" + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.POST_NOTIFICATIONS + ) + + @Test + @ScreenshotTest + @Throws(IOException::class) + fun displaySimpleTextFile() { + launchActivity().use { scenario -> + var sut: FileDisplayActivity? = null + scenario.onActivity { activity -> + sut = activity + val test = OCFile("/text.md").apply { + mimeType = MimeTypeUtil.MIMETYPE_TEXT_MARKDOWN + storagePath = getDummyFile("nonEmpty.txt").absolutePath + } + sut.startTextPreview(test, true) + } + + val screenShotName = createName(testClassName + "_" + "displaySimpleTextFile", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + @ScreenshotTest + @Throws(IOException::class) + fun displayJavaSnippetFile() { + launchActivity().use { scenario -> + var sut: FileDisplayActivity? = null + scenario.onActivity { activity -> + sut = activity + val test = OCFile("/java.md").apply { + mimeType = MimeTypeUtil.MIMETYPE_TEXT_MARKDOWN + storagePath = getFile("java.md").absolutePath + } + sut.startTextPreview(test, true) + } + + val screenShotName = createName(testClassName + "_" + "displayJavaSnippetFile", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/preview/pdf/PreviewPdfFragmentScreenshotIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/preview/pdf/PreviewPdfFragmentScreenshotIT.kt new file mode 100644 index 000000000000..bdece91549f9 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/preview/pdf/PreviewPdfFragmentScreenshotIT.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.preview.pdf + +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.nextcloud.test.TestActivity +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class PreviewPdfFragmentScreenshotIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.preview.pdf.PreviewPdfFragmentScreenshotIT" + + companion object { + private const val PDF_FILE_ASSET = "test.pdf" + } + + @Test + @ScreenshotTest + fun showPdf() { + launchActivity().use { scenario -> + var activity: TestActivity? = null + scenario.onActivity { testActivity -> + activity = testActivity + val pdfFile = getFile(PDF_FILE_ASSET) + val ocFile = OCFile("/test.pdf").apply { + storagePath = pdfFile.absolutePath + } + + val sut = PreviewPdfFragment.newInstance(ocFile) + activity.addFragment(sut) + activity.supportFragmentManager.executePendingTransactions() + sut.dismissSnack() + } + + val screenShotName = createName(testClassName + "_" + "showPdf", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(activity, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/trashbin/TrashbinActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/trashbin/TrashbinActivityIT.kt new file mode 100644 index 000000000000..8fa372d229d6 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/trashbin/TrashbinActivityIT.kt @@ -0,0 +1,161 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.trashbin + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Intent +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import com.owncloud.android.AbstractIT +import com.owncloud.android.MainApp +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Test + +class TrashbinActivityIT : AbstractIT() { + private val testClassName = "com.owncloud.android.ui.trashbin.TrashbinActivityIT" + + enum class TestCase { + ERROR, + EMPTY, + FILES + } + + @Test + @ScreenshotTest + fun error() { + launchActivity().use { scenario -> + var sut: TrashbinActivity? = null + scenario.onActivity { activity -> + sut = activity + val trashbinRepository = TrashbinLocalRepository(TestCase.ERROR) + sut.trashbinPresenter = TrashbinPresenter(trashbinRepository, sut) + sut.loadFolder( + onComplete = { }, + onError = { } + ) + } + + val screenShotName = createName(testClassName + "_" + "error", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + @ScreenshotTest + fun files() { + launchActivity().use { scenario -> + var sut: TrashbinActivity? = null + scenario.onActivity { activity -> + sut = activity + val trashbinRepository = TrashbinLocalRepository(TestCase.FILES) + sut.trashbinPresenter = TrashbinPresenter(trashbinRepository, sut) + sut.loadFolder( + onComplete = { }, + onError = { } + ) + } + + val screenShotName = createName(testClassName + "_" + "files", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + @ScreenshotTest + fun empty() { + launchActivity().use { scenario -> + var sut: TrashbinActivity? = null + scenario.onActivity { activity -> + sut = activity + val trashbinRepository = TrashbinLocalRepository(TestCase.EMPTY) + sut.trashbinPresenter = TrashbinPresenter(trashbinRepository, sut) + sut.loadFolder( + onComplete = { }, + onError = { } + ) + } + + val screenShotName = createName(testClassName + "_" + "empty", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + @ScreenshotTest + fun loading() { + launchActivity().use { scenario -> + var sut: TrashbinActivity? = null + scenario.onActivity { activity -> + sut = activity + val trashbinRepository = TrashbinLocalRepository(TestCase.EMPTY) + sut.trashbinPresenter = TrashbinPresenter(trashbinRepository, sut) + sut.showInitialLoading() + } + + val screenShotName = createName(testClassName + "_" + "loading", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + @ScreenshotTest + fun normalUser() { + launchActivity().use { scenario -> + var sut: TrashbinActivity? = null + scenario.onActivity { activity -> + sut = activity + val trashbinRepository = TrashbinLocalRepository(TestCase.EMPTY) + sut.trashbinPresenter = TrashbinPresenter(trashbinRepository, sut) + sut.showUser() + } + + val screenShotName = createName(testClassName + "_" + "normalUser", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } + + @Test + @ScreenshotTest + fun differentUser() { + val temp = Account("differentUser@https://nextcloud.localhost", MainApp.getAccountType(targetContext)) + + AccountManager.get(targetContext).apply { + addAccountExplicitly(temp, "password", null) + setUserData(temp, AccountUtils.Constants.KEY_OC_BASE_URL, "https://nextcloud.localhost") + setUserData(temp, AccountUtils.Constants.KEY_USER_ID, "differentUser") + } + + val intent = Intent(targetContext, TrashbinActivity::class.java).apply { + putExtra(Intent.EXTRA_USER, "differentUser@https://nextcloud.localhost") + } + + launchActivity().use { scenario -> + var sut: TrashbinActivity? = null + scenario.onActivity { activity -> + sut = activity + val trashbinRepository = TrashbinLocalRepository(TestCase.EMPTY) + sut.trashbinPresenter = TrashbinPresenter(trashbinRepository, sut) + sut.showUser() + } + + val screenShotName = createName(testClassName + "_" + "differentUser", "") + onView(isRoot()).check(matches(isDisplayed())) + screenshotViaName(sut, screenShotName) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/trashbin/TrashbinLocalRepository.kt b/app/src/androidTest/java/com/owncloud/android/ui/trashbin/TrashbinLocalRepository.kt new file mode 100644 index 000000000000..ae90c9569748 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/trashbin/TrashbinLocalRepository.kt @@ -0,0 +1,77 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.trashbin + +import com.owncloud.android.R +import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile +import com.owncloud.android.ui.trashbin.TrashbinRepository.LoadFolderCallback + +class TrashbinLocalRepository(private val testCase: TrashbinActivityIT.TestCase) : TrashbinRepository { + override fun emptyTrashbin(callback: TrashbinRepository.OperationCallback?) { + TODO("Not yet implemented") + } + + override fun restoreFile(file: TrashbinFile?, callback: TrashbinRepository.OperationCallback?) { + TODO("Not yet implemented") + } + + override fun removeTrashbinFile(file: TrashbinFile?, callback: TrashbinRepository.OperationCallback?) { + TODO("Not yet implemented") + } + + @Suppress("MagicNumber") + override fun getFolder(remotePath: String?, callback: LoadFolderCallback?) { + when (testCase) { + TrashbinActivityIT.TestCase.ERROR -> callback?.onError(R.string.trashbin_loading_failed) + + TrashbinActivityIT.TestCase.FILES -> { + val files = ArrayList() + files.add( + TrashbinFile( + "test.png", + "image/png", + "/trashbin/test.png", + "subFolder/test.png", + // random date + 1395847838, + // random date + 1395847908 + ) + ) + files.add( + TrashbinFile( + "image.jpg", + "image/jpeg", + "/trashbin/image.jpg", + "image.jpg", + // random date + 1395841858, + // random date + 1395837858 + ) + ) + files.add( + TrashbinFile( + "folder", + "DIR", + "/trashbin/folder/", + "folder", + // random date + 1395347858, + // random date + 1395849858 + ) + ) + + callback?.onSuccess(files) + } + + TrashbinActivityIT.TestCase.EMPTY -> callback?.onSuccess(ArrayList()) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java b/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java new file mode 100644 index 000000000000..f794af49c67d --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java @@ -0,0 +1,874 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.util; + +import android.text.TextUtils; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; +import com.nextcloud.test.RandomStringGenerator; +import com.nextcloud.test.RetryTestRule; +import com.owncloud.android.AbstractIT; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.e2e.v1.decrypted.Data; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata; +import com.owncloud.android.datamodel.e2e.v1.decrypted.Encrypted; +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.e2ee.CsrHelper; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.crypto.CryptoHelper; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.security.DigestInputStream; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPublicKey; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; + +import static com.owncloud.android.utils.EncryptionUtils.decodeStringToBase64Bytes; +import static com.owncloud.android.utils.EncryptionUtils.decryptFile; +import static com.owncloud.android.utils.EncryptionUtils.decryptFolderMetaData; +import static com.owncloud.android.utils.EncryptionUtils.decryptStringAsymmetric; +import static com.owncloud.android.utils.EncryptionUtils.decryptStringSymmetric; +import static com.owncloud.android.utils.EncryptionUtils.deserializeJSON; +import static com.owncloud.android.utils.EncryptionUtils.encodeBytesToBase64String; +import static com.owncloud.android.utils.EncryptionUtils.encryptFolderMetadata; +import static com.owncloud.android.utils.EncryptionUtils.generateChecksum; +import static com.owncloud.android.utils.EncryptionUtils.generateKey; +import static com.owncloud.android.utils.EncryptionUtils.generateSHA512; +import static com.owncloud.android.utils.EncryptionUtils.isFolderMigrated; +import static com.owncloud.android.utils.EncryptionUtils.ivDelimiter; +import static com.owncloud.android.utils.EncryptionUtils.ivDelimiterOld; +import static com.owncloud.android.utils.EncryptionUtils.ivLength; +import static com.owncloud.android.utils.EncryptionUtils.randomBytes; +import static com.owncloud.android.utils.EncryptionUtils.saltLength; +import static com.owncloud.android.utils.EncryptionUtils.serializeJSON; +import static com.owncloud.android.utils.EncryptionUtils.verifySHA512; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; + +public class EncryptionTestIT extends AbstractIT { + @Rule public RetryTestRule retryTestRule = new RetryTestRule(); + + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(targetContext); + + private static final String MD5_ALGORITHM = "MD5"; + + private static final String filename = "ia7OEEEyXMoRa1QWQk8r"; + private static final String secondFilename = "n9WXAIXO2wRY4R8nXwmo"; + + public static final String privateKey = "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAo" + + "IBAQDsn0JKS/THu328z1IgN0VzYU53HjSX03WJIgWkmyTaxbiKpoJaKbksXmfSpgzV" + + "GzKFvGfZ03fwFrN7Q8P8R2e8SNiell7mh1TDw9/0P7Bt/ER8PJrXORo+GviKHxaLr7" + + "Y0BJX9i/nW/L0L/VaE8CZTAqYBdcSJGgHJjY4UMf892ZPTa9T2Dl3ggdMZ7BQ2kiCi" + + "CC3qV99b0igRJGmmLQaGiAflhFzuDQPMifUMq75wI8RSRPdxUAtjTfkl68QHu7Umye" + + "yy33OQgdUKaTl5zcS3VSQbNjveVCNM4RDH1RlEc+7Wf1BY8APqT6jbiBcROJD2CeoL" + + "H2eiIJCi+61ZkSGfAgMBAAECggEBALFStCHrhBf+GL9a+qer4/8QZ/X6i91PmaBX/7" + + "SYk2jjjWVSXRNmex+V6+Y/jBRT2mvAgm8J+7LPwFdatE+lz0aZrMRD2gCWYF6Itpda" + + "90OlLkmQPVWWtGTgX2ta2tF5r2iSGzk0IdoL8zw98Q2UzpOcw30KnWtFMxuxWk0mHq" + + "pgp00g80cDWg3+RPbWOhdLp5bflQ36fKDfmjq05cGlIk6unnVyC5HXpvh4d4k2EWlX" + + "rjGsndVBPCjGkZePlLRgDHxT06r+5XdJ+1CBDZgCsmjGz3M8uOHyCfVW0WhB7ynzDT" + + "agVgz0iqpuhAi9sPt6iWWwpAnRw8cQgqEKw9bvKKECgYEA/WPi2PJtL6u/xlysh/H7" + + "A717CId6fPHCMDace39ZNtzUzc0nT5BemlcF0wZ74NeJSur3Q395YzB+eBMLs5p8mA" + + "95wgGvJhM65/J+HX+k9kt6Z556zLMvtG+j1yo4D0VEwm3xahB4SUUP+1kD7dNvo4+8" + + "xeSCyjzNllvYZZC0DrECgYEA7w8pEqhHHn0a+twkPCZJS+gQTB9Rm+FBNGJqB3XpWs" + + "TeLUxYRbVGk0iDve+eeeZ41drxcdyWP+WcL34hnrjgI1Fo4mK88saajpwUIYMy6+qM" + + "LY+jC2NRSBox56eH7nsVYvQQK9eKqv9wbB+PF9SwOIvuETN7fd8mAY02UnoaaU8CgY" + + "BoHRKocXPLkpZJuuppMVQiRUi4SHJbxDo19Tp2w+y0TihiJ1lvp7I3WGpcOt3LlMQk" + + "tEbExSvrRZGxZKH6Og/XqwQsYuTEkEIz679F/5yYVosE6GkskrOXQAfh8Mb3/04xVV" + + "tMaVgDQw0+CWVD4wyL+BNofGwBDNqsXTCdCsfxAQKBgQCDv2EtbRw0y1HRKv21QIxo" + + "ju5cZW4+cDfVPN+eWPdQFOs1H7wOPsc0aGRiiupV2BSEF3O1ApKziEE5U1QH+29bR4" + + "R8L1pemeGX8qCNj5bCubKjcWOz5PpouDcEqimZ3q98p3E6GEHN15UHoaTkx0yO/V8o" + + "j6zhQ9fYRxDHB5ACtQKBgQCOO7TJUO1IaLTjcrwS4oCfJyRnAdz49L1AbVJkIBK0fh" + + "JLecOFu3ZlQl/RStQb69QKb5MNOIMmQhg8WOxZxHcpmIDbkDAm/J/ovJXFSoBdOr5o" + + "uQsYsDZhsWW97zvLMzg5pH9/3/1BNz5q3Vu4HgfBSwWGt4E2NENj+XA+QAVmGA=="; + + public static final String publicKey = "-----BEGIN CERTIFICATE-----\n" + + "MIIDpzCCAo+gAwIBAgIBADANBgkqhkiG9w0BAQUFADBuMRowGAYDVQQDDBF3d3cu\n" + + "bmV4dGNsb3VkLmNvbTESMBAGA1UECgwJTmV4dGNsb3VkMRIwEAYDVQQHDAlTdHV0\n" + + "dGdhcnQxGzAZBgNVBAgMEkJhZGVuLVd1ZXJ0dGVtYmVyZzELMAkGA1UEBhMCREUw\n" + + "HhcNMTcwOTI2MTAwNDMwWhcNMzcwOTIxMTAwNDMwWjBuMRowGAYDVQQDDBF3d3cu\n" + + "bmV4dGNsb3VkLmNvbTESMBAGA1UECgwJTmV4dGNsb3VkMRIwEAYDVQQHDAlTdHV0\n" + + "dGdhcnQxGzAZBgNVBAgMEkJhZGVuLVd1ZXJ0dGVtYmVyZzELMAkGA1UEBhMCREUw\n" + + "ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDsn0JKS/THu328z1IgN0Vz\n" + + "YU53HjSX03WJIgWkmyTaxbiKpoJaKbksXmfSpgzVGzKFvGfZ03fwFrN7Q8P8R2e8\n" + + "SNiell7mh1TDw9/0P7Bt/ER8PJrXORo+GviKHxaLr7Y0BJX9i/nW/L0L/VaE8CZT\n" + + "AqYBdcSJGgHJjY4UMf892ZPTa9T2Dl3ggdMZ7BQ2kiCiCC3qV99b0igRJGmmLQaG\n" + + "iAflhFzuDQPMifUMq75wI8RSRPdxUAtjTfkl68QHu7Umyeyy33OQgdUKaTl5zcS3\n" + + "VSQbNjveVCNM4RDH1RlEc+7Wf1BY8APqT6jbiBcROJD2CeoLH2eiIJCi+61ZkSGf\n" + + "AgMBAAGjUDBOMB0GA1UdDgQWBBTFrXz2tk1HivD9rQ75qeoyHrAgIjAfBgNVHSME\n" + + "GDAWgBTFrXz2tk1HivD9rQ75qeoyHrAgIjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3\n" + + "DQEBBQUAA4IBAQARQTX21QKO77gAzBszFJ6xVnjfa23YZF26Z4X1KaM8uV8TGzuN\n" + + "JA95XmReeP2iO3r8EWXS9djVCD64m2xx6FOsrUI8HZaw1JErU8mmOaLAe8q9RsOm\n" + + "9Eq37e4vFp2YUEInYUqs87ByUcA4/8g3lEYeIUnRsRsWsA45S3wD7wy07t+KAn7j\n" + + "yMmfxdma6hFfG9iN/egN6QXUAyIPXvUvlUuZ7/BhWBj/3sHMrF9quy9Q2DOI8F3t\n" + + "1wdQrkq4BtStKhciY5AIXz9SqsctFHTv4Lwgtkapoel4izJnO0ZqYTXVe7THwri9\n" + + "H/gua6uJDWH9jk2/CiZDWfsyFuNUuXvDSp05\n" + + "-----END CERTIFICATE-----"; + + @Test + public void encryptStringAsymmetric() throws Exception { + byte[] key1 = generateKey(); + String base64encodedKey = encodeBytesToBase64String(key1); + + String encryptedString = EncryptionUtils.encryptStringAsymmetric(base64encodedKey, publicKey); + String decryptedString = decryptStringAsymmetric(encryptedString, privateKey); + + byte[] key2 = decodeStringToBase64Bytes(decryptedString); + + assertArrayEquals(key1, key2); + } + + @Test + public void encryptStringAsymmetricCorrectPublicKey() throws Exception { + KeyPair keyPair = EncryptionUtils.generateKeyPair(); + + byte[] key1 = generateKey(); + String base64encodedKey = encodeBytesToBase64String(key1); + + String encryptedString = EncryptionUtils.encryptStringAsymmetric(base64encodedKey, keyPair.getPublic()); + String decryptedString = decryptStringAsymmetric(encryptedString, keyPair.getPrivate()); + + byte[] key2 = decodeStringToBase64Bytes(decryptedString); + + assertArrayEquals(key1, key2); + } + + @Test(expected = BadPaddingException.class) + public void encryptStringAsymmetricWrongPublicKey() throws Exception { + KeyPair keyPair1 = EncryptionUtils.generateKeyPair(); + KeyPair keyPair2 = EncryptionUtils.generateKeyPair(); + + byte[] key1 = generateKey(); + String base64encodedKey = encodeBytesToBase64String(key1); + + String encryptedString = EncryptionUtils.encryptStringAsymmetric(base64encodedKey, keyPair1.getPublic()); + decryptStringAsymmetric(encryptedString, keyPair2.getPrivate()); + } + + @Test + public void testModulus() throws Exception { + KeyPair keyPair = EncryptionUtils.generateKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyPair.getPrivate(); + + BigInteger modulusPublic = publicKey.getModulus(); + BigInteger modulusPrivate = privateKey.getModulus(); + + assertEquals(modulusPrivate, modulusPublic); + } + + @Test + public void encryptStringSymmetricRandom() throws Exception { + int max = 500; + for (int i = 0; i < max; i++) { + Log_OC.d("EncryptionTestIT", i + " of " + max); + byte[] key = generateKey(); + + String encryptedString; + if (new Random().nextBoolean()) { + encryptedString = EncryptionUtils.encryptStringSymmetricAsString(privateKey, key); + } else { + encryptedString = EncryptionUtils.encryptStringSymmetricAsStringOld(privateKey, key); + + if (encryptedString.indexOf(ivDelimiterOld) != encryptedString.lastIndexOf(ivDelimiterOld)) { + Log_OC.d("EncryptionTestIT", "skip due to duplicated iv (old system) -> ignoring"); + continue; + } + } + String decryptedString = decryptStringSymmetric(encryptedString, key); + + assertEquals(privateKey, decryptedString); + } + } + + @Test + public void encryptStringSymmetric() throws Exception { + int max = 5000; + byte[] key = generateKey(); + + for (int i = 0; i < max; i++) { + Log_OC.d("EncryptionTestIT", i + " of " + max); + + String encryptedString = EncryptionUtils.encryptStringSymmetricAsString(privateKey, key); + + int delimiterPosition = encryptedString.indexOf(ivDelimiter); + if (delimiterPosition == -1) { + throw new RuntimeException("IV not found!"); + } + + String ivString = encryptedString.substring(delimiterPosition + ivDelimiter.length()); + if (TextUtils.isEmpty(ivString)) { + delimiterPosition = encryptedString.lastIndexOf(ivDelimiter); + ivString = encryptedString.substring(delimiterPosition + ivDelimiter.length()); + + if (TextUtils.isEmpty(ivString)) { + throw new RuntimeException("IV string is empty"); + } + } + + String decryptedString = decryptStringSymmetric(encryptedString, key); + + assertEquals(privateKey, decryptedString); + } + } + + @Test + public void encryptPrivateKey() throws Exception { + int max = 10; + for (int i = 0; i < max; i++) { + Log_OC.d("EncryptionTestIT", i + " of " + max); + + String keyPhrase = "moreovertelevisionfactorytendencyindependenceinternationalintellectualimpress" + + "interestvolunteer"; + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(4096, new SecureRandom()); + KeyPair keyPair = keyGen.generateKeyPair(); + PrivateKey privateKey = keyPair.getPrivate(); + byte[] privateKeyBytes = privateKey.getEncoded(); + String privateKeyString = encodeBytesToBase64String(privateKeyBytes); + + String encryptedString = CryptoHelper.INSTANCE.encryptPrivateKey(privateKeyString, keyPhrase); + String decryptedString = CryptoHelper.INSTANCE.decryptPrivateKey(encryptedString, keyPhrase); + + assertEquals(privateKeyString, decryptedString); + } + } + + @Test + public void generateCSR() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyGen.generateKeyPair(); + + assertFalse(new CsrHelper().generateCsrPemEncodedString(keyPair, "").isEmpty()); + assertFalse(encodeBytesToBase64String(keyPair.getPublic().getEncoded()).isEmpty()); + } + + + /** + * DecryptedFolderMetadataFile -> EncryptedFolderMetadataFile -> JSON -> encrypt -> decrypt -> JSON -> + * EncryptedFolderMetadataFile -> DecryptedFolderMetadataFile + */ + @Test + public void encryptionMetadataV1() throws Exception { + DecryptedFolderMetadataFileV1 decryptedFolderMetadata1 = generateFolderMetadataV1_1(); + + // encrypt + EncryptedFolderMetadataFileV1 encryptedFolderMetadata1 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + 1, + user, + arbitraryDataProvider); + + // serialize + String encryptedJson = serializeJSON(encryptedFolderMetadata1); + + // de-serialize + EncryptedFolderMetadataFileV1 encryptedFolderMetadata2 = deserializeJSON(encryptedJson, + new TypeToken<>() { + }); + + // decrypt + DecryptedFolderMetadataFileV1 decryptedFolderMetadata2 = decryptFolderMetaData( + encryptedFolderMetadata2, + privateKey, + arbitraryDataProvider, + user, + 1); + + // compare + assertTrue(compareJsonStrings(serializeJSON(decryptedFolderMetadata1), + serializeJSON(decryptedFolderMetadata2))); + } + + @Test + public void testChangedMetadataKey() throws Exception { + DecryptedFolderMetadataFileV1 decryptedFolderMetadata1 = generateFolderMetadataV1_1(); + long folderID = 1; + + // encrypt + EncryptedFolderMetadataFileV1 encryptedFolderMetadata1 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + + // store metadata key + String oldMetadataKey = encryptedFolderMetadata1.getMetadata().getMetadataKey(); + + // do it again + // encrypt + EncryptedFolderMetadataFileV1 encryptedFolderMetadata2 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + + String newMetadataKey = encryptedFolderMetadata2.getMetadata().getMetadataKey(); + + assertNotEquals(oldMetadataKey, newMetadataKey); + } + + @Test + public void testMigrateMetadataKey() throws Exception { + DecryptedFolderMetadataFileV1 decryptedFolderMetadata1 = generateFolderMetadataV1_1(); + long folderID = 1; + + // encrypt + EncryptedFolderMetadataFileV1 encryptedFolderMetadata1 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + + // reset new metadata key, to mimic old version + encryptedFolderMetadata1.getMetadata().setMetadataKey(null); + String oldMetadataKey = encryptedFolderMetadata1.getMetadata().getMetadataKey(); + + // do it again + // encrypt + EncryptedFolderMetadataFileV1 encryptedFolderMetadata2 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + + String newMetadataKey = encryptedFolderMetadata2.getMetadata().getMetadataKey(); + + assertNotEquals(oldMetadataKey, newMetadataKey); + } + + @Test + public void testCryptFileWithoutMetadata() throws Exception { + byte[] key = decodeStringToBase64Bytes("WANM0gRv+DhaexIsI0T3Lg=="); + byte[] iv = decodeStringToBase64Bytes("gKm3n+mJzeY26q4OfuZEqg=="); + + assertTrue(cryptFile(filename, "78f42172166f9dc8fd1a7156b1753353", key, iv)); + } + + @Test + public void cryptFileWithMetadata() throws Exception { + DecryptedFolderMetadataFileV1 metadata = generateFolderMetadataV1_1(); + + assertTrue(cryptFile(filename, + "78f42172166f9dc8fd1a7156b1753353", + decodeStringToBase64Bytes(metadata.getFiles().get(filename) + .getEncrypted().getKey()), + decodeStringToBase64Bytes(metadata.getFiles().get(filename) + .getInitializationVector()))); + + assertTrue(cryptFile(secondFilename, + "825143ed1f21ebb0c3b3c3f005b2f5db", + decodeStringToBase64Bytes(metadata.getFiles().get(secondFilename) + .getEncrypted().getKey()), + decodeStringToBase64Bytes(metadata.getFiles().get(secondFilename) + .getInitializationVector()))); + } + + @Test + public void bigMetadata() throws Exception { + DecryptedFolderMetadataFileV1 decryptedFolderMetadata1 = generateFolderMetadataV1_1(); + long folderID = 1; + + // encrypt + EncryptedFolderMetadataFileV1 encryptedFolderMetadata1 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + + // serialize + String encryptedJson = serializeJSON(encryptedFolderMetadata1); + + // de-serialize + EncryptedFolderMetadataFileV1 encryptedFolderMetadata2 = deserializeJSON(encryptedJson, + new TypeToken<>() { + }); + + // decrypt + DecryptedFolderMetadataFileV1 decryptedFolderMetadata2 = decryptFolderMetaData( + encryptedFolderMetadata2, + privateKey, + arbitraryDataProvider, + user, + folderID); + + // compare + assertTrue(compareJsonStrings(serializeJSON(decryptedFolderMetadata1), + serializeJSON(decryptedFolderMetadata2))); + + // prefill with 500 + for (int i = 0; i < 500; i++) { + addFile(decryptedFolderMetadata1, i); + } + + int max = 505; + for (int i = 500; i < max; i++) { + Log_OC.d(this, "Big metadata: " + i + " of " + max); + + addFile(decryptedFolderMetadata1, i); + + // encrypt + encryptedFolderMetadata1 = encryptFolderMetadata(decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + + // serialize + encryptedJson = serializeJSON(encryptedFolderMetadata1); + + // de-serialize + encryptedFolderMetadata2 = deserializeJSON(encryptedJson, + new TypeToken<>() { + }); + + // decrypt + decryptedFolderMetadata2 = decryptFolderMetaData(encryptedFolderMetadata2, + privateKey, + arbitraryDataProvider, + user, + folderID); + + // compare + assertTrue(compareJsonStrings(serializeJSON(decryptedFolderMetadata1), + serializeJSON(decryptedFolderMetadata2))); + + assertEquals(i + 3, decryptedFolderMetadata1.getFiles().size()); + assertEquals(i + 3, decryptedFolderMetadata2.getFiles().size()); + } + } + + @Test + public void bigMetadata2() throws Exception { + long folderID = 1; + DecryptedFolderMetadataFileV1 decryptedFolderMetadata1 = generateFolderMetadataV1_1(); + + // encrypt + EncryptedFolderMetadataFileV1 encryptedFolderMetadata1 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + + // serialize + String encryptedJson = serializeJSON(encryptedFolderMetadata1); + + // de-serialize + EncryptedFolderMetadataFileV1 encryptedFolderMetadata2 = deserializeJSON(encryptedJson, + new TypeToken<>() { + }); + + // decrypt + DecryptedFolderMetadataFileV1 decryptedFolderMetadata2 = decryptFolderMetaData( + encryptedFolderMetadata2, + privateKey, + arbitraryDataProvider, + user, + folderID); + + // compare + assertTrue(compareJsonStrings(serializeJSON(decryptedFolderMetadata1), + serializeJSON(decryptedFolderMetadata2))); + + // prefill with 500 + for (int i = 0; i < 500; i++) { + addFile(decryptedFolderMetadata1, i); + } + + int max = 505; + for (int i = 500; i < max; i++) { + Log_OC.d(this, "Big metadata: " + i + " of " + max); + + addFile(decryptedFolderMetadata1, i); + + // encrypt + encryptedFolderMetadata1 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + + // serialize + encryptedJson = serializeJSON(encryptedFolderMetadata1); + + // de-serialize + encryptedFolderMetadata2 = deserializeJSON(encryptedJson, + new TypeToken<>() { + }); + + // decrypt + decryptedFolderMetadata2 = decryptFolderMetaData( + encryptedFolderMetadata2, + privateKey, + arbitraryDataProvider, + user, + folderID); + + // compare + assertTrue(compareJsonStrings(serializeJSON(decryptedFolderMetadata1), + serializeJSON(decryptedFolderMetadata2))); + + assertEquals(i + 3, decryptedFolderMetadata1.getFiles().size()); + assertEquals(i + 3, decryptedFolderMetadata2.getFiles().size()); + } + } + + @Test + public void filedrop() throws Exception { + DecryptedFolderMetadataFileV1 decryptedFolderMetadata1 = generateFolderMetadataV1_1(); + long folderID = 1; + + // add filedrop + Map filesdrop = new HashMap<>(); + + Data data = new Data(); + data.setKey("9dfzbIYDt28zTyZfbcll+g=="); + data.setFilename("test2.txt"); + data.setVersion(1); + + DecryptedFile file = new DecryptedFile(); + file.setInitializationVector("hnJLF8uhDvDoFK4ajuvwrg=="); + file.setEncrypted(data); + file.setMetadataKey(0); + file.setAuthenticationTag("qOQZdu5soFO77Y7y4rAOVA=="); + + filesdrop.put("eie8iaeiaes8e87td6", file); + + decryptedFolderMetadata1.setFiledrop(filesdrop); + + // encrypt + EncryptedFolderMetadataFileV1 encryptedFolderMetadata1 = encryptFolderMetadata( + decryptedFolderMetadata1, + publicKey, + folderID, + user, + arbitraryDataProvider); + EncryptionUtils.encryptFileDropFiles(decryptedFolderMetadata1, encryptedFolderMetadata1, publicKey); + + // serialize + String encryptedJson = serializeJSON(encryptedFolderMetadata1, true); + + // de-serialize + EncryptedFolderMetadataFileV1 encryptedFolderMetadata2 = deserializeJSON(encryptedJson, + new TypeToken<>() { + }); + + // decrypt + DecryptedFolderMetadataFileV1 decryptedFolderMetadata2 = decryptFolderMetaData( + encryptedFolderMetadata2, + privateKey, + arbitraryDataProvider, + user, + folderID); + + // compare + assertFalse(compareJsonStrings(serializeJSON(decryptedFolderMetadata1, true), + serializeJSON(decryptedFolderMetadata2, true))); + + assertEquals(decryptedFolderMetadata1.getFiles().size() + decryptedFolderMetadata1.getFiledrop().size(), + decryptedFolderMetadata2.getFiles().size()); + + // no filedrop content means null + assertNull(decryptedFolderMetadata2.getFiledrop()); + } + + private void addFile(DecryptedFolderMetadataFileV1 decryptedFolderMetadata, int counter) { + // Add new file + // Always generate new + byte[] key = generateKey(); + byte[] iv = randomBytes(ivLength); + byte[] authTag = randomBytes((128 / 8)); + + Data data = new Data(); + data.setKey(EncryptionUtils.encodeBytesToBase64String(key)); + data.setFilename(counter + ".txt"); + data.setVersion(1); + + DecryptedFile file = new DecryptedFile(); + file.setInitializationVector(EncryptionUtils.encodeBytesToBase64String(iv)); + file.setEncrypted(data); + file.setMetadataKey(0); + file.setAuthenticationTag(EncryptionUtils.encodeBytesToBase64String(authTag)); + + decryptedFolderMetadata.getFiles().put(RandomStringGenerator.make(20), file); + } + + /** + * generates new keys and tests if they are unique + */ + @Test + public void testKey() { + Set keys = new HashSet<>(); + + for (int i = 0; i < 50; i++) { + assertTrue(keys.add(encodeBytesToBase64String(generateKey()))); + } + } + + /** + * generates new ivs and tests if they are unique + */ + @Test + public void testIV() { + Set ivs = new HashSet<>(); + + for (int i = 0; i < 50; i++) { + assertTrue(ivs.add(encodeBytesToBase64String( + randomBytes(ivLength)))); + } + } + + /** + * generates new salt and tests if they are unique + */ + @Test + public void testSalt() { + Set ivs = new HashSet<>(); + + for (int i = 0; i < 50; i++) { + assertTrue(ivs.add(encodeBytesToBase64String( + randomBytes(saltLength)))); + } + } + + @Test + public void testSHA512() { + // sent to 3rd party app in cleartext + String token = "4ae5978bf5354cd284b539015d442141"; + String salt = encodeBytesToBase64String(randomBytes(saltLength)); + + // stored in database + String hashedToken = generateSHA512(token, salt); + + // check: use passed cleartext and salt to verify hashed token + assertTrue(verifySHA512(hashedToken, token)); + } + + @Test + public void testExcludeGSON() throws Exception { + DecryptedFolderMetadataFileV1 metadata = generateFolderMetadataV1_1(); + + String jsonWithKeys = serializeJSON(metadata); + String jsonWithoutKeys = serializeJSON(metadata, true); + + assertTrue(jsonWithKeys.contains("metadataKeys")); + assertFalse(jsonWithoutKeys.contains("metadataKeys")); + } + + @Test + public void testEqualsSign() { + assertEquals("\"===\"", serializeJSON("===")); + } + + @Test + public void testBase64() { + String originalString = "randomstring123"; + + String encodedString = EncryptionUtils.encodeStringToBase64String(originalString); + String compare = EncryptionUtils.decodeBase64StringToString(encodedString); + assertEquals(originalString, compare); + } + + @Test + public void testChecksum() throws Exception { + DecryptedFolderMetadataFileV1 metadata = new DecryptedFolderMetadataFileV1(); + String mnemonic = "chimney potato joke science ridge trophy result estate spare vapor much room"; + + metadata.getFiles().put(secondFilename, new DecryptedFile()); + metadata.getFiles().put(filename, new DecryptedFile()); + + String encryptedMetadataKey = "GuFPAULudgD49S4+VDFck3LiqQ8sx4zmbrBtdpCSGcT+T0W0z4F5gYQYPlzTG6WOkdW5LJZK/"; + metadata.getMetadata().setMetadataKey(encryptedMetadataKey); + + String checksum = generateChecksum(metadata, mnemonic); + + String expectedChecksum = "002cefa6493f2efb0192247a34bb1b16d391aefee968fd3d4225c4ec3cd56436"; + assertEquals(expectedChecksum, checksum); + + // change something + String newMnemonic = mnemonic + "1"; + + String newChecksum = generateChecksum(metadata, newMnemonic); + assertNotEquals(expectedChecksum, newChecksum); + + metadata.getFiles().put("aeb34yXMoRa1QWQk8r", new DecryptedFile()); + + newChecksum = generateChecksum(metadata, mnemonic); + assertNotEquals(expectedChecksum, newChecksum); + } + + @Test + public void testAddIdToMigratedIds() { + // delete ids + arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), EncryptionUtils.MIGRATED_FOLDER_IDS); + + long id = 1; + EncryptionUtils.addIdToMigratedIds(id, user, arbitraryDataProvider); + + assertTrue(isFolderMigrated(id, user, arbitraryDataProvider)); + } + + // TODO E2E: more tests + + // more tests + // migrate v1 -> v2 + // migrate v1 -> v2 with filedrop + + // migrate v1 -> v1.1 + // migrate v1 -> v1.1 with filedrop + + // migrate v1.1 -> v2 + // migrate v1.1 -> v2 with filedrop + + + // Helper + public static boolean compareJsonStrings(String expected, String actual) { + JsonElement o1 = JsonParser.parseString(expected); + JsonElement o2 = JsonParser.parseString(actual); + + if (o1.equals(o2)) { + return true; + } else { + System.out.println("expected: " + o1); + System.out.println("actual: " + o2); + return false; + } + } + + private DecryptedFolderMetadataFileV1 generateFolderMetadataV1_1() throws Exception { + String metadataKey0 = encodeBytesToBase64String(generateKey()); + String metadataKey1 = encodeBytesToBase64String(generateKey()); + String metadataKey2 = encodeBytesToBase64String(generateKey()); + HashMap metadataKeys = new HashMap<>(); + metadataKeys.put(0, EncryptionUtils.encryptStringAsymmetric(metadataKey0, publicKey)); + metadataKeys.put(1, EncryptionUtils.encryptStringAsymmetric(metadataKey1, publicKey)); + metadataKeys.put(2, EncryptionUtils.encryptStringAsymmetric(metadataKey2, publicKey)); + Encrypted encrypted = new Encrypted(); + encrypted.setMetadataKeys(metadataKeys); + + DecryptedMetadata metadata1 = new DecryptedMetadata(); + metadata1.setMetadataKeys(metadataKeys); + metadata1.setVersion(1); + + HashMap files = new HashMap<>(); + + Data data1 = new Data(); + data1.setKey("WANM0gRv+DhaexIsI0T3Lg=="); + data1.setFilename("test.txt"); + data1.setVersion(1); + + DecryptedFile file1 = new DecryptedFile(); + file1.setInitializationVector("gKm3n+mJzeY26q4OfuZEqg=="); + file1.setEncrypted(data1); + file1.setMetadataKey(0); + file1.setAuthenticationTag("PboI9tqHHX3QeAA22PIu4w=="); + + files.put(filename, file1); + + Data data2 = new Data(); + data2.setKey("9dfzbIYDt28zTyZfbcll+g=="); + data2.setFilename("test2.txt"); + data2.setVersion(1); + + DecryptedFile file2 = new DecryptedFile(); + file2.setInitializationVector("hnJLF8uhDvDoFK4ajuvwrg=="); + file2.setEncrypted(data2); + file2.setMetadataKey(0); + file2.setAuthenticationTag("qOQZdu5soFO77Y7y4rAOVA=="); + + files.put(secondFilename, file2); + + return new DecryptedFolderMetadataFileV1(metadata1, files); + } + + private boolean cryptFile(String fileName, String md5, byte[] key, byte[] iv) + throws Exception { + File file = File.createTempFile(fileName, "enc"); + String md5BeforeEncryption = getMD5Sum(file); + + // Encryption + Cipher encryptorCipher = EncryptionUtils.getCipher(Cipher.ENCRYPT_MODE, key, iv); + EncryptionUtils.encryptFile(user.getAccountName(), file, encryptorCipher); + String encryptorCipherAuthTag = EncryptionUtils.getAuthenticationTag(encryptorCipher); + + // Decryption + Cipher decryptorCipher = EncryptionUtils.getCipher(Cipher.DECRYPT_MODE, key, iv); + File decryptedFile = File.createTempFile("file", "dec"); + decryptFile(decryptorCipher, file, decryptedFile, encryptorCipherAuthTag, new ArbitraryDataProviderImpl(targetContext), user); + + String md5AfterEncryption = getMD5Sum(decryptedFile); + + if (md5BeforeEncryption == null) { + Assert.fail(); + } + + return md5BeforeEncryption.equals(md5AfterEncryption); + } + + public static String getMD5Sum(File file) { + try (FileInputStream fis = new FileInputStream(file)) { + MessageDigest md = MessageDigest.getInstance(MD5_ALGORITHM); + DigestInputStream dis = new DigestInputStream(fis, md); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = dis.read(buffer)) != -1) { + md.update(buffer, 0, bytesRead); + } + byte[] digest = md.digest(); + return bytesToHex(digest); + } catch (IOException | NoSuchAlgorithmException e) { + return null; + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/util/ErrorMessageAdapterIT.java b/app/src/androidTest/java/com/owncloud/android/util/ErrorMessageAdapterIT.java new file mode 100644 index 000000000000..1e6b4ee01f11 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/util/ErrorMessageAdapterIT.java @@ -0,0 +1,55 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021-2022 Chris Narkiewicz + * SPDX-FileCopyrightText: 2019-2021 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.util; + +import android.content.Context; +import android.content.res.Resources; + +import com.nextcloud.client.account.MockUser; +import com.nextcloud.client.account.User; +import com.owncloud.android.MainApp; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.operations.RemoveFileOperation; +import com.owncloud.android.utils.ErrorMessageAdapter; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import static junit.framework.TestCase.assertEquals; + +@RunWith(AndroidJUnit4.class) +public class ErrorMessageAdapterIT { + private final static String PATH_TO_DELETE = "/path/to/a.file"; + private final static String EXPECTED_ERROR_MESSAGE = "You are not permitted to delete this file"; + private final static String ACCOUNT_TYPE = "nextcloud"; + + @Test + public void getErrorCauseMessageForForbiddenRemoval() { + Resources resources = InstrumentationRegistry.getInstrumentation().getTargetContext().getResources(); + User user = new MockUser("name", ACCOUNT_TYPE); + Context context = MainApp.getAppContext(); + + String errorMessage = ErrorMessageAdapter.getErrorCauseMessage( + new RemoteOperationResult(RemoteOperationResult.ResultCode.FORBIDDEN), + new RemoveFileOperation(new OCFile(PATH_TO_DELETE), + false, + user, + false, + context, + new FileDataStorageManager(user, context.getContentResolver())), + resources + ); + + assertEquals(EXPECTED_ERROR_MESSAGE, errorMessage); + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/BitmapUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/BitmapUtilsIT.kt new file mode 100644 index 000000000000..d9cbcf8627a8 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/BitmapUtilsIT.kt @@ -0,0 +1,65 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class BitmapUtilsIT { + @Test + @Suppress("MagicNumber") + fun usernameToColor() { + assertEquals(BitmapUtils.Color(0, 0, 0), BitmapUtils.Color(0, 0, 0)) + assertEquals(BitmapUtils.Color(221, 203, 85), BitmapUtils.usernameToColor("User")) + assertEquals(BitmapUtils.Color(208, 158, 109), BitmapUtils.usernameToColor("Admin")) + assertEquals(BitmapUtils.Color(0, 130, 201), BitmapUtils.usernameToColor("")) + assertEquals(BitmapUtils.Color(201, 136, 121), BitmapUtils.usernameToColor("68b329da9893e34099c7d8ad5cb9c940")) + + // tests from server + assertEquals(BitmapUtils.Color(208, 158, 109), BitmapUtils.usernameToColor("Alishia Ann Lowry")) + assertEquals(BitmapUtils.Color(0, 130, 201), BitmapUtils.usernameToColor("Arham Johnson")) + assertEquals(BitmapUtils.Color(208, 158, 109), BitmapUtils.usernameToColor("Brayden Truong")) + assertEquals(BitmapUtils.Color(151, 80, 164), BitmapUtils.usernameToColor("Daphne Roy")) + assertEquals(BitmapUtils.Color(195, 114, 133), BitmapUtils.usernameToColor("Ellena Wright Frederic Conway")) + assertEquals(BitmapUtils.Color(214, 180, 97), BitmapUtils.usernameToColor("Gianluca Hills")) + assertEquals(BitmapUtils.Color(214, 180, 97), BitmapUtils.usernameToColor("Haseeb Stephens")) + assertEquals(BitmapUtils.Color(151, 80, 164), BitmapUtils.usernameToColor("Idris Mac")) + assertEquals(BitmapUtils.Color(0, 130, 201), BitmapUtils.usernameToColor("Kristi Fisher")) + assertEquals(BitmapUtils.Color(188, 92, 145), BitmapUtils.usernameToColor("Lillian Wall")) + assertEquals(BitmapUtils.Color(221, 203, 85), BitmapUtils.usernameToColor("Lorelai Taylor")) + assertEquals(BitmapUtils.Color(151, 80, 164), BitmapUtils.usernameToColor("Madina Knight")) + assertEquals(BitmapUtils.Color(121, 90, 171), BitmapUtils.usernameToColor("Rae Hope")) + assertEquals(BitmapUtils.Color(188, 92, 145), BitmapUtils.usernameToColor("Santiago Singleton")) + assertEquals(BitmapUtils.Color(208, 158, 109), BitmapUtils.usernameToColor("Sid Combs")) + assertEquals(BitmapUtils.Color(30, 120, 193), BitmapUtils.usernameToColor("Vivienne Jacobs")) + assertEquals(BitmapUtils.Color(110, 166, 143), BitmapUtils.usernameToColor("Zaki Cortes")) + assertEquals(BitmapUtils.Color(91, 100, 179), BitmapUtils.usernameToColor("a user")) + assertEquals(BitmapUtils.Color(208, 158, 109), BitmapUtils.usernameToColor("admin")) + assertEquals(BitmapUtils.Color(151, 80, 164), BitmapUtils.usernameToColor("admin@cloud.example.com")) + assertEquals(BitmapUtils.Color(221, 203, 85), BitmapUtils.usernameToColor("another user")) + assertEquals(BitmapUtils.Color(36, 142, 181), BitmapUtils.usernameToColor("asd")) + assertEquals(BitmapUtils.Color(0, 130, 201), BitmapUtils.usernameToColor("bar")) + assertEquals(BitmapUtils.Color(208, 158, 109), BitmapUtils.usernameToColor("foo")) + assertEquals(BitmapUtils.Color(182, 70, 157), BitmapUtils.usernameToColor("wasd")) + } + + @Test + @Suppress("MagicNumber") + fun checkEqual() { + assertEquals(BitmapUtils.Color(208, 158, 109), BitmapUtils.Color(208, 158, 109)) + assertNotEquals(BitmapUtils.Color(208, 158, 109), BitmapUtils.Color(208, 158, 100)) + } + + @Test + @Suppress("MagicNumber") + fun checkHashCode() { + assertEquals(BitmapUtils.Color(208, 158, 109).hashCode(), BitmapUtils.Color(208, 158, 109).hashCode()) + assertNotEquals(BitmapUtils.Color(208, 158, 109).hashCode(), BitmapUtils.Color(208, 158, 100).hashCode()) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/DisplayUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/DisplayUtilsIT.kt new file mode 100644 index 000000000000..df69c14e052e --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/DisplayUtilsIT.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import com.owncloud.android.AbstractIT +import org.junit.Assert.assertEquals +import org.junit.Test + +class DisplayUtilsIT : AbstractIT() { + @Test + fun testPixelToDP() { + val px = 123 + val dp = DisplayUtils.convertPixelToDp(px, targetContext) + val newPx = DisplayUtils.convertDpToPixel(dp, targetContext) + + assertEquals(px.toLong(), newPx.toLong()) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/DrawableUtilTests.kt b/app/src/androidTest/java/com/owncloud/android/utils/DrawableUtilTests.kt new file mode 100644 index 000000000000..d75016327026 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/DrawableUtilTests.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Before +import org.junit.Test + +class DrawableUtilTests { + + private var context: Context? = null + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + } + + @Test + fun testAddDrawableAsOverlayWhenGivenValidDrawablesShouldContainTwoDrawable() { + val bitmap: Bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) + val drawable = BitmapDrawable(context?.resources, bitmap) + + val layerDrawable = DrawableUtil.addDrawableAsOverlay(drawable, drawable) + + assert(layerDrawable.numberOfLayers == 2) + } + + @After + fun destroy() { + context = null + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionTestUtils.kt b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionTestUtils.kt new file mode 100644 index 000000000000..34d6621bed3a --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionTestUtils.kt @@ -0,0 +1,142 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedUser +import com.owncloud.android.lib.resources.status.E2EVersion + +class EncryptionTestUtils { + val t1PrivateKey = + "MIIEugIBADANBgkqhkiG9w0BAQEFAASCBKQwggSgAgEAAoIBAQC1p8eYMFwGoi7geYzEwNbePRLL5LRhorAecFG3zkpLBwSi/QHkU4" + + "u4uSegEbHgOfe73eKVOFdfFpw8wd5cvtY+4CzbX8bu+yrC+tFGcJ25/4VQ78Bl4MI0SvOmxDwuZNrg9SWgs9RwialKOsfCEyz0" + + "SS8RstGNt5KZKn1e8z7V9X/eORPmOQ5KcIXHlMbAY3m4erBSKvhRZdqy+Dbnc0rZeZaKkoIMJH1OYfVto/ek12iIKF2YStPVzo" + + "TgNsFelPDxeA/lltgf6qDVRD+ELydEncPIJwcv52D8ZitoEyEOfjDZW+rvvE02g1ZD1xPkDLpwltAsFCglCKvKBAWuhthFAgMB" + + "AAECgf8BN1MLcq+6m8C1tzNvN/UDd9c0rUpexM6D5eC4O+6B7YGidEqIhHVIzUj0e2HUgpRBbURxsvF1FWdIT2gu7dnULtOGWQ" + + "xNujJ0kGwXfAnqxh/rACDFb5TS3sJawEExC5yJw14bCEbE/0uBF5uiTU/U9AV7PKHlqAKsS2RtcwPNceB8zDu0hh/Mb/uS7274" + + "TsxUllx0WzGZrozO1K6AlOete9rXmmpghpFTNVhxgf0pxe3hrK+tZGSL9di+Wft9eCvSbdG/FzeXgwVqmGtWU7kSB7FqstEEJO" + + "4VpOSyEfcXGHTHwdZjrhBUuAcjWE8E0mCKa8htRE52czb3C0f7ZYkCgYEA5eH3vmHEgQjXzSSEtbmDLRq9X9SB7pIAIXHj2UuE" + + "OTkLUJ/7xLTHqt82jqZaZzns1RZIJXKZjH85CswQp/py2/qD240KvA/N+ELZaciaV+Wg+m4+iHdi0DyPkaKaBtFG1nsR2GbVWO" + + "1OsaTUZTG4D7RCUErU6XVmNPQKSk5uRA0CgYEAykskpX3KKuWq5nxV4vwgPmxz+uAfCtaGhcPEUg764SR+n0ODAvGiEJU7B0Q2" + + "oX621pDOQeRfFufiMWfD8ByhErs1HFCmW69YPlR8qamfc8tHG5UM+r3bb49sDEYU4qr1Ji5Zzs4XgfmToKLbWdzzhaW6YxqO7N" + + "ntIIh2169PPxkCgYBF2TAWl8xGTLKNcYAlW1XBObO6z24fWBtUDi/mEWz+mheXCtVMAoX8pFAGbgNgBBiy8k8/mZ+QMgPaBQE2" + + "mQGXV3oDFsrhM4go298Fpl9HP8126lJz0pqinRQecyKL2cDFYKWedDh1Cb30ehnTGZVMqD/R97rTqMlCY7hQtZ4JbQKBgEXpLD" + + "QJQeoLT0GybJgyXA5WuspT1EaRlxH5cwqM5MUUMLJnyYol6cVjXXAIcfzj5tpGVxHMk9Q9tR0v6DY+HqhzjEpJ0QRUl+GKnz6f" + + "QVzqPpvYqhCptoFahpPDUIp5XJmiYSUoclVX5F4aikYHJx3kBYMkdYqDUgDxSGkHzBJZAoGAHV44xgTW02dgeB5GfDJVWCJKAU" + + "GsYOFuUehKUBXSJ0929hdP0sjOQDJN3DEDISzmgdWX5NyLJxEYgFWNivpePjWCWzOzyua3nPSpvxPIUB7xh27gjT91glj1hEmy" + + "sCd7+9yoMPiCXR7iigRycxegI/Krd39QzISSk9O0finfytU=" + + val t1PublicKey = """-----BEGIN CERTIFICATE----- +MIIC6DCCAdCgAwIBAgIBADANBgkqhkiG9w0BAQUFADANMQswCQYDVQQDDAJ0MTAe +Fw0yMzA3MjUwNzU3MTJaFw00MzA3MjAwNzU3MTJaMA0xCzAJBgNVBAMMAnQxMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtafHmDBcBqIu4HmMxMDW3j0S +y+S0YaKwHnBRt85KSwcEov0B5FOLuLknoBGx4Dn3u93ilThXXxacPMHeXL7WPuAs +21/G7vsqwvrRRnCduf+FUO/AZeDCNErzpsQ8LmTa4PUloLPUcImpSjrHwhMs9Ekv +EbLRjbeSmSp9XvM+1fV/3jkT5jkOSnCFx5TGwGN5uHqwUir4UWXasvg253NK2XmW +ipKCDCR9TmH1baP3pNdoiChdmErT1c6E4DbBXpTw8XgP5ZbYH+qg1UQ/hC8nRJ3D +yCcHL+dg/GYraBMhDn4w2Vvq77xNNoNWQ9cT5Ay6cJbQLBQoJQirygQFrobYRQID +AQABo1MwUTAdBgNVHQ4EFgQUE9zCeA9/QMAtVgLxD23X6ZcodhMwHwYDVR0jBBgw +FoAUE9zCeA9/QMAtVgLxD23X6ZcodhMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG +9w0BAQUFAAOCAQEAZdy/YjJlvnz3FQwxp6oVtMJccpdxveEPfLzgaverhtd/vP8O +AvDzOLgQJHmrDS91SG503eU4cYGyuNKwd77OyTnqMg+GUEmJhGfPpSVrEIdh65jv +q61T4oqBdehevVmBq54rGiwL0DGv1DlXQlwiJZP4qni2KnOEFcnvL3gVtRnQjXQ+ +kHvlMshkK6w021EMV5NfjG2zg67wC65rLaej5f6Ssp2S7g2VtmE4aXq1bjAuEbqk +4TiyZHLDdsJuqzyGyyOpMV7i9ucXDoaZt9cGS9hT2vRxTrSH63vKR8Xeig9+stLw +t9ONcUqCKP7hd8rajtxM4JIIRExwD8OkgARWGg== +-----END CERTIFICATE-----""" + + val johnPrivateKey = + """MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDuPcSvlhqElPQsCdJEuGmptGj4TUBWe33yu+ncOYR8Ec3M0H4NL0gE + |ORJJcz9i18ByLpNzDy6NUGOtlf9YSat/zKdAfFiZJolKc/y4BPfTr8xx5ml2mu4Rz39LXRru+nnhluV3g1h2Z9LvWhUVUqAztz9W2H + |H6uC7jx+7HNtYC9VgsVzHjuHPQMlOePPZlr9Hry5enF/Psn24RdiKqwCz8WhsOwtmW5PdHLLBVHAoF53URnFR4sgmLLGlS2GEZ8hvx + |vdV/2NmhRWLebmCZziyklAe9gCR9lgfN32tqzyMG7VptBHFy7YJidWjpjSZPGEqFBL+fmCO/cTGJAXfCn9djAgMBAAECggEAV2QBCg + |edopShHKZdoyeiWsX621o7B341LR0RI99VYc2GGGNCWcPGPwZQVvEXh0JtLXU4UTR4dw3OApbLG6+qYS7JCzaRqVwhcFYrlbT804Hh + |FMbYWNFsEsxyfUqh3peyrbWUZsqfYI+lKHd61F+CtHW7nje3V6jISnXEeP78cgioKOX8gsCG8DEWsmaLrQz0PyMwdhucRfa8Bm6qeX + |NY+wCMg8lyH/+OLlyCZTdkaWbTBBD5UXGbZly8iX17McmsYhdjFyx1l0NQnVMAYjOpXXEkeEixZpSfm3GYxmdaQqZFkpbI/FbQF0yD + |7hLrGwiRTDcyPUz+QypUv8CZxpXbgQKBgQD3btuYmb+BpPZjryfa3worv/3XQCTs08V0TX3mDxHVQL95TgP+L8/Z/brxIMBNpwG1wk + |iCWLYLer68+qioMTohuzeUx7hRKcoHa9ezW8m7m9AcPmAnzNticPYv835BQjEu/avU98rwIDihsYgxcjU3L7/P2ajVgUDigQxmE3gO + |OwKBgQD2fXBLwch0P5g2GCyOPYvSgyF/umS7mcyUVTE4WOoJNDf8Q+Bx1dA2yAKQaoVghqW4uCfOAo/rERvGAYZ7fm7nFwx1jZ8ToT + |dKZwybIFPjF/zkfuZLajYxVOPnzuQrsXnjcGg/ltMKZg3NqnGQGnD1S3eOhZ+dIOBmb7+jSO4A+QKBgASqnpGeNLJpPgxbPVEva62v + |jUYF+6xLwimTXJB+MEPpWLMc+Y5NsInX8zKg/393atzWsS9kJOrKgdZmk8+4PfRs53ty2NMPCrRhIExNqtxS7/XYZ0/Y2TpeDwaQfQ + |0WBn9wYVE+6yDkOq0x//OOx9ommGN/I2QDcAnVjTpPm7AJAoGAYT8cDsdlTnfIlY70BSpC/8q8bKgdFeaXz+3MfW6W5wqzC9O7uS2h + |9/rxCAj+lhaJS1dcXOql3Rfi3Tu80vwOxR1SzQ4StKvmJHSDhLA8aFwOahemxBojR1M2lz4IxzQ94n12o5/dozygNYQJSdEkv6IGiT + |QuxM8zuTZdZQ5g2AECgYAujetfkwgVW7/gumpMKytoY0VuTzF4Y/XZfqBMVIiPIuUl57JbDzrcx6YVXX3PavxNWmBLBmMq3SHMbdva + |H7LnU/8rvkT8xRVLg/w/bRJc3Lb3oUjrdhkUQUYDoOfMoFA+ceZ2L6bnSXwm86KKV+xoXWpxAoL4AvdNrMhoWw3+yg==""" + .trimMargin() + + val johnPublicKey = """-----BEGIN CERTIFICATE----- +MIIDkDCCAnigAwIBAgIBADANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQGEwJERTEb +MBkGA1UECAwSQmFkZW4tV3VlcnR0ZW1iZXJnMRIwEAYDVQQHDAlTdHV0dGdhcnQx +EjAQBgNVBAoMCU5leHRjbG91ZDENMAsGA1UEAwwEam9objAeFw0yMzA3MTQwNzM0 +NTZaFw00MzA3MDkwNzM0NTZaMGExCzAJBgNVBAYTAkRFMRswGQYDVQQIDBJCYWRl +bi1XdWVydHRlbWJlcmcxEjAQBgNVBAcMCVN0dXR0Z2FydDESMBAGA1UECgwJTmV4 +dGNsb3VkMQ0wCwYDVQQDDARqb2huMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA7j3Er5YahJT0LAnSRLhpqbRo+E1AVnt98rvp3DmEfBHNzNB+DS9IBDkS +SXM/YtfAci6Tcw8ujVBjrZX/WEmrf8ynQHxYmSaJSnP8uAT306/MceZpdpruEc9/ +S10a7vp54Zbld4NYdmfS71oVFVKgM7c/Vthx+rgu48fuxzbWAvVYLFcx47hz0DJT +njz2Za/R68uXpxfz7J9uEXYiqsAs/FobDsLZluT3RyywVRwKBed1EZxUeLIJiyxp +UthhGfIb8b3Vf9jZoUVi3m5gmc4spJQHvYAkfZYHzd9ras8jBu1abQRxcu2CYnVo +6Y0mTxhKhQS/n5gjv3ExiQF3wp/XYwIDAQABo1MwUTAdBgNVHQ4EFgQUmTeILVuB +tv70fTGkXWGAueDp5kAwHwYDVR0jBBgwFoAUmTeILVuBtv70fTGkXWGAueDp5kAw +DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAyVtq9XAvW7nxSW/8 +hp30z6xbzGiuviXhy/Jo91VEa8IRsWCCn3OmDFiVduTEowx76tf8clJP0gk7Pozi +6dg/7Fin+FqQGXfCk8bLAh9gXKAikQ2GK8yRN3slRFwYC2mm23HrLdKXZHUqJcpB +Mz2zsSrOGPj1YsYOl/U8FU6KA7Yj7U3q7kDMYTAgzUPZAH+d1DISGWpZsMa0RYid +vigCCLByiccmS/Co4Sb1esF58H+YtV5+nFBRwx881U2g2TgDKF1lPMK/y3d8B8mh +UtW+lFxRpvyNUDpsMjOErOrtNFEYbgoUJLtqwBMmyGR+nmmh6xna331QWcRAmw0P +nDO4ew== +-----END CERTIFICATE-----""" + + @Throws(java.lang.Exception::class) + fun generateFolderMetadataV2(userId: String, cert: String): DecryptedFolderMetadataFile { + val metadata = DecryptedMetadata().apply { + metadataKey = EncryptionUtils.generateKey() + keyChecksums.add(EncryptionUtilsV2().hashMetadataKey(metadataKey)) + } + + val file1 = DecryptedFile( + "image1.png", + "image/png", + "gKm3n+mJzeY26q4OfuZEqg==", + "PboI9tqHHX3QeAA22PIu4w==", + "WANM0gRv+DhaexIsI0T3Lg==" + ) + + val file2 = DecryptedFile( + "image2.png", + "image/png", + "hnJLF8uhDvDoFK4ajuvwrg==", + "qOQZdu5soFO77Y7y4rAOVA==", + "9dfzbIYDt28zTyZfbcll+g==" + ) + + val users = mutableListOf( + DecryptedUser(userId, cert, null) + ) + + // val filedrop = mutableMapOf( + // Pair( + // "eie8iaeiaes8e87td6", + // DecryptedFile( + // "test2.txt", + // "txt/plain", + // "hnJLF8uhDvDoFK4ajuvwrg==", + // "qOQZdu5soFO77Y7y4rAOVA==", + // "9dfzbIYDt28zTyZfbcll+g==" + // ) + // ) + // ) + + metadata.files["ia7OEEEyXMoRa1QWQk8r"] = file1 + metadata.files["n9WXAIXO2wRY4R8nXwmo"] = file2 + + return DecryptedFolderMetadataFile(metadata, users, mutableMapOf(), E2EVersion.V2_0.value) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt new file mode 100644 index 000000000000..50c9815771c6 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt @@ -0,0 +1,59 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import com.owncloud.android.EncryptionIT +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.e2e.v1.decrypted.Data +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata +import com.owncloud.android.lib.resources.e2ee.CsrHelper +import com.owncloud.android.operations.RefreshFolderOperation +import org.junit.Assert.assertEquals +import org.junit.Test + +class EncryptionUtilsIT : EncryptionIT() { + @Throws( + java.security.NoSuchAlgorithmException::class, + java.io.IOException::class, + org.bouncycastle.operator.OperatorCreationException::class + ) + @Test + fun saveAndRestorePublicKey() { + val arbitraryDataProvider = ArbitraryDataProviderImpl(targetContext) + val keyPair = EncryptionUtils.generateKeyPair() + val e2eUser = "e2e-user" + val key = CsrHelper().generateCsrPemEncodedString(keyPair, e2eUser) + + EncryptionUtils.savePublicKey(user, key, e2eUser, arbitraryDataProvider) + + assertEquals(key, EncryptionUtils.getPublicKey(user, e2eUser, arbitraryDataProvider)) + } + + @Test + @Throws(Exception::class) + fun testUpdateFileNameForEncryptedFileV1() { + val folder = testFolder() + + val decryptedFilename = "image.png" + val mockEncryptedFilename = "encrypted_file_name.png" + + val decryptedMetadata = DecryptedMetadata() + val filesData = DecryptedFile().apply { + encrypted = Data().apply { + filename = decryptedFilename + } + } + val files = mapOf(mockEncryptedFilename to filesData) + val metadata = DecryptedFolderMetadataFileV1(decryptedMetadata, files) + + RefreshFolderOperation.updateFileNameForEncryptedFileV1(storageManager, metadata, folder) + assertEquals(folder.decryptedRemotePath.contains("null"), false) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt new file mode 100644 index 000000000000..3ab05fdeaf0e --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt @@ -0,0 +1,919 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import com.google.gson.reflect.TypeToken +import com.nextcloud.client.account.MockUser +import com.nextcloud.common.User +import com.nextcloud.utils.extensions.findMetadataKeyByUserId +import com.owncloud.android.EncryptionIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.e2e.v1.decrypted.Data +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedUser +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFiledrop +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFiledropUser +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFolderMetadataFile +import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.util.EncryptionTestIT +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.junit.Assert.assertNotEquals +import org.junit.Test + +@Suppress("TooManyFunctions", "LargeClass") +class EncryptionUtilsV2IT : EncryptionIT() { + private val encryptionTestUtils = EncryptionTestUtils() + private val encryptionUtilsV2 = EncryptionUtilsV2() + + private val enc1UserId = "enc1" + private val enc1PrivateKey = """ + MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAo + IBAQDsn0JKS/THu328z1IgN0VzYU53HjSX03WJIgWkmyTaxbiKpoJaKbksXmfSpgzV + GzKFvGfZ03fwFrN7Q8P8R2e8SNiell7mh1TDw9/0P7Bt/ER8PJrXORo+GviKHxaLr7 + Y0BJX9i/nW/L0L/VaE8CZTAqYBdcSJGgHJjY4UMf892ZPTa9T2Dl3ggdMZ7BQ2kiCi + CC3qV99b0igRJGmmLQaGiAflhFzuDQPMifUMq75wI8RSRPdxUAtjTfkl68QHu7Umye + yy33OQgdUKaTl5zcS3VSQbNjveVCNM4RDH1RlEc+7Wf1BY8APqT6jbiBcROJD2CeoL + H2eiIJCi+61ZkSGfAgMBAAECggEBALFStCHrhBf+GL9a+qer4/8QZ/X6i91PmaBX/7 + SYk2jjjWVSXRNmex+V6+Y/jBRT2mvAgm8J+7LPwFdatE+lz0aZrMRD2gCWYF6Itpda + 90OlLkmQPVWWtGTgX2ta2tF5r2iSGzk0IdoL8zw98Q2UzpOcw30KnWtFMxuxWk0mHq + pgp00g80cDWg3+RPbWOhdLp5bflQ36fKDfmjq05cGlIk6unnVyC5HXpvh4d4k2EWlX + rjGsndVBPCjGkZePlLRgDHxT06r+5XdJ+1CBDZgCsmjGz3M8uOHyCfVW0WhB7ynzDT + agVgz0iqpuhAi9sPt6iWWwpAnRw8cQgqEKw9bvKKECgYEA/WPi2PJtL6u/xlysh/H7 + A717CId6fPHCMDace39ZNtzUzc0nT5BemlcF0wZ74NeJSur3Q395YzB+eBMLs5p8mA + 95wgGvJhM65/J+HX+k9kt6Z556zLMvtG+j1yo4D0VEwm3xahB4SUUP+1kD7dNvo4+8 + xeSCyjzNllvYZZC0DrECgYEA7w8pEqhHHn0a+twkPCZJS+gQTB9Rm+FBNGJqB3XpWs + TeLUxYRbVGk0iDve+eeeZ41drxcdyWP+WcL34hnrjgI1Fo4mK88saajpwUIYMy6+qM + LY+jC2NRSBox56eH7nsVYvQQK9eKqv9wbB+PF9SwOIvuETN7fd8mAY02UnoaaU8CgY + BoHRKocXPLkpZJuuppMVQiRUi4SHJbxDo19Tp2w+y0TihiJ1lvp7I3WGpcOt3LlMQk + tEbExSvrRZGxZKH6Og/XqwQsYuTEkEIz679F/5yYVosE6GkskrOXQAfh8Mb3/04xVV + tMaVgDQw0+CWVD4wyL+BNofGwBDNqsXTCdCsfxAQKBgQCDv2EtbRw0y1HRKv21QIxo + ju5cZW4+cDfVPN+eWPdQFOs1H7wOPsc0aGRiiupV2BSEF3O1ApKziEE5U1QH+29bR4 + R8L1pemeGX8qCNj5bCubKjcWOz5PpouDcEqimZ3q98p3E6GEHN15UHoaTkx0yO/V8o + j6zhQ9fYRxDHB5ACtQKBgQCOO7TJUO1IaLTjcrwS4oCfJyRnAdz49L1AbVJkIBK0fh + JLecOFu3ZlQl/RStQb69QKb5MNOIMmQhg8WOxZxHcpmIDbkDAm/J/ovJXFSoBdOr5o + uQsYsDZhsWW97zvLMzg5pH9/3/1BNz5q3Vu4HgfBSwWGt4E2NENj+XA+QAVmGA== + """.trimIndent() + + private val enc1Cert = """ + -----BEGIN CERTIFICATE----- + MIIDpzCCAo+gAwIBAgIBADANBgkqhkiG9w0BAQUFADBuMRowGAYDVQQDDBF3d3cu + bmV4dGNsb3VkLmNvbTESMBAGA1UECgwJTmV4dGNsb3VkMRIwEAYDVQQHDAlTdHV0 + dGdhcnQxGzAZBgNVBAgMEkJhZGVuLVd1ZXJ0dGVtYmVyZzELMAkGA1UEBhMCREUw + HhcNMTcwOTI2MTAwNDMwWhcNMzcwOTIxMTAwNDMwWjBuMRowGAYDVQQDDBF3d3cu + bmV4dGNsb3VkLmNvbTESMBAGA1UECgwJTmV4dGNsb3VkMRIwEAYDVQQHDAlTdHV0 + dGdhcnQxGzAZBgNVBAgMEkJhZGVuLVd1ZXJ0dGVtYmVyZzELMAkGA1UEBhMCREUw + ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDsn0JKS/THu328z1IgN0Vz + YU53HjSX03WJIgWkmyTaxbiKpoJaKbksXmfSpgzVGzKFvGfZ03fwFrN7Q8P8R2e8 + SNiell7mh1TDw9/0P7Bt/ER8PJrXORo+GviKHxaLr7Y0BJX9i/nW/L0L/VaE8CZT + AqYBdcSJGgHJjY4UMf892ZPTa9T2Dl3ggdMZ7BQ2kiCiCC3qV99b0igRJGmmLQaG + iAflhFzuDQPMifUMq75wI8RSRPdxUAtjTfkl68QHu7Umyeyy33OQgdUKaTl5zcS3 + VSQbNjveVCNM4RDH1RlEc+7Wf1BY8APqT6jbiBcROJD2CeoLH2eiIJCi+61ZkSGf + AgMBAAGjUDBOMB0GA1UdDgQWBBTFrXz2tk1HivD9rQ75qeoyHrAgIjAfBgNVHSME + GDAWgBTFrXz2tk1HivD9rQ75qeoyHrAgIjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3 + DQEBBQUAA4IBAQARQTX21QKO77gAzBszFJ6xVnjfa23YZF26Z4X1KaM8uV8TGzuN + JA95XmReeP2iO3r8EWXS9djVCD64m2xx6FOsrUI8HZaw1JErU8mmOaLAe8q9RsOm + 9Eq37e4vFp2YUEInYUqs87ByUcA4/8g3lEYeIUnRsRsWsA45S3wD7wy07t+KAn7j + yMmfxdma6hFfG9iN/egN6QXUAyIPXvUvlUuZ7/BhWBj/3sHMrF9quy9Q2DOI8F3t + 1wdQrkq4BtStKhciY5AIXz9SqsctFHTv4Lwgtkapoel4izJnO0ZqYTXVe7THwri9 + H/gua6uJDWH9jk2/CiZDWfsyFuNUuXvDSp05 + -----END CERTIFICATE----- + """.trimIndent() + + private val enc2Cert = """ + -----BEGIN CERTIFICATE----- + MIIC7DCCAdSgAwIBAgIBADANBgkqhkiG9w0BAQUFADAPMQ0wCwYDVQQDDARlbmMz + MB4XDTIwMDcwODA3MzE1OFoXDTQwMDcwMzA3MzE1OFowDzENMAsGA1UEAwwEZW5j + MzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAI/83eC/EF3xOocwjO+Z + ZkPc1TFxt3aUgjEvrpZu45LOqesG67kkkVDYgjeg3Biz9XRUQXqtXaAyxRZH8GiH + PFyKUiP1bUlCptd8X+hk9vxeN25YS5OS2RrxU9tDQ/dVOHr20427UvVCighotQnR + /6+md1FQMV92PFxji7OP5TWOE1y389X6eb7kSPLs8Tu+2PpqaNVQ9C/89Y8KNYWs + x9Zo+kbQhjfFFUikEpkuzMgT9QLaeq6xuXIPP+y1tzNmF6NTL0a2GoYULuxYWnCe + joFyXj77LuLmK+KXfPdhvlxa5Kl9XHSxKPHBVVQpwPqNMT+b2T1VLE2l7M9NfImy + iLcCAwEAAaNTMFEwHQYDVR0OBBYEFBKubDeR2lXwuyTrdyv6O7euPS4PMB8GA1Ud + IwQYMBaAFBKubDeR2lXwuyTrdyv6O7euPS4PMA8GA1UdEwEB/wQFMAMBAf8wDQYJ + KoZIhvcNAQEFBQADggEBAChCOIH8CkEpm1eqjsuuNPa93aduLjtnZXat5eIKsKCl + rL9nFslpg/DO5SeU5ynPY9F2QjX5CN/3RxDXum9vFfpXhTJphOv8N0uHU4ucmQxE + DN388Vt5VtN3V2pzNUL3JSiG6qeYG047/r/zhGFVpcgb2465G5mEwFT0qnkEseCC + VVZ63GN8hZgUobyRXxMIhkfWlbO1dgABB4VNyudq0CW8urmewkkbUBwCslvtUvPM + WuzpQjq2A80bvbrAqO5VUfvMcqRiUWkDgfa6cHXyV0o4N11mMIoxsMgh+PFYr6lR + BHkuQHqKEwP8kkWugIFj3TMcy9dYtXfMXWvzFaDoE4s= + -----END CERTIFICATE----- + """.trimIndent() + + private val enc2PrivateKey = """ + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCP/N3gvxBd8TqH + MIzvmWZD3NUxcbd2lIIxL66WbuOSzqnrBuu5JJFQ2II3oNwYs/V0VEF6rV2gMsUW + R/BohzxcilIj9W1JQqbXfF/oZPb8XjduWEuTktka8VPbQ0P3VTh69tONu1L1QooI + aLUJ0f+vpndRUDFfdjxcY4uzj+U1jhNct/PV+nm+5Ejy7PE7vtj6amjVUPQv/PWP + CjWFrMfWaPpG0IY3xRVIpBKZLszIE/UC2nqusblyDz/stbczZhejUy9GthqGFC7s + WFpwno6Bcl4++y7i5ivil3z3Yb5cWuSpfVx0sSjxwVVUKcD6jTE/m9k9VSxNpezP + TXyJsoi3AgMBAAECggEACWwKFtlZ2FPfORZ3unwGwZ0TRFOFJljMdiyBF6307Vfh + rZP729clPS2Vw88eZ+1qu+yBhmYO0NtRo0Yc2LI0xHd2rYyzVI5sfYBRhFMLCHOf + 2/QiKet7knRFQP1TVr14Xy+Eo2slIBB1GNzFL/nSaeuSNjtxp6YEiCUpcJwTayAi + Squ5QWMxhlciLKvwUkraFRBqkugvMz3jXzuk/i+DcYlOgoj+tytweNn/azOMH9MH + mWI+3owYspjzE1rVpbrcWImvlnbInd0z9KaQPpBf7Njj7wtyBMaYww4K4GCMhboD + SQCYgpnznWkPIN3jyXtmNVSsZ1nvD+Laod+0p7giOQKBgQDA6KEKctYpbt051yTe + 2UP8hpq+MUSS7FIXiHlUc8s0PSujouypUyzfrPeL6yquI0GtKHkMVCWwfT+otMZR + VnklofrmPTPovvsUQFM4Di411NZwzfxEbBFyVXAUWcLd9NxJ1hZW7w+hLk/N5Bej + DOa2CncZmifyMNIlvIn7T1vDyQKBgQC/FE8HaDBoN98m/3rEjx7/rVtX8dCei5By + Fzg/yQ2u4ELbf/Qk/n4k75sy0690EwnFdJxVn2gdNgS1YDv8YP/N5Wfq8xnX9V9B + irWY/W24cN2qDNXm5i8o5wklyt+fDVqMcEHFfONUpLC+RYmOdc1rrFxPaQOYYYpp + dWsnuG0ofwKBgBm6rUf8ew35qG3/gP5sEgJLXbZCUfgapvRWkoAuFYs5IWno4BHR + cym+IyI5Um75atgSjtqTGpfIjMYOnmjY1L2tNg6hWRwQ5OIVlkPiuE0bvyI6hwwF + MeqC9LjyI+iAsSTz9fTQW9BOofw/ENwBa4AaMzpp8iv+UPkRhYHMWtvpAoGAX6As + RMqxnxaHCR9GM2Rk4RPC6OpNu2qhKVfRgKp/vIrjKrKIXpM2UgnPo8oovnBgrX7E + Vl1mX2gPRy4YFx/8JPCv5vcucdOMjmJ6q0v5QxrI9DdkPR/pbhDhlRZIf3LRZAMy + B0GPC2c4RKDMTI1L9pzVvbASaoo2GLz4mXJEvsUCgYEAibwFNXz1H52sZtL6/1zQ + 1rHCTS8qkryBhxl5eYa6MV5YkbLJZZstF0w2nLxkPba8NttS/nJqjX/iJobD5uLb + UzeD8jMeAWPNt4DZCtA4ossNYcXIMKqBVFKOANMvAAvLMpVdlNYSucNnTSQcLwI6 + 2J9mW5WvAAaG+j28Q/GKSuE= + """.trimIndent() + + @Test + fun testEncryptDecryptMetadata() { + val metadataKey = EncryptionUtils.generateKey() + + val metadata = DecryptedMetadata( + mutableListOf("hash1", "hash of key 2"), + false, + 1, + mutableMapOf( + Pair(EncryptionUtils.generateUid(), "Folder 1"), + Pair(EncryptionUtils.generateUid(), "Folder 2"), + Pair(EncryptionUtils.generateUid(), "Folder 3") + ), + mutableMapOf( + Pair( + EncryptionUtils.generateUid(), + DecryptedFile( + "file 1.png", + "image/png", + "initializationVector", + "authenticationTag", + "key 1" + ) + ), + Pair( + EncryptionUtils.generateUid(), + DecryptedFile( + "file 2.png", + "image/png", + "initializationVector 2", + "authenticationTag 2", + "key 2" + ) + ) + ), + metadataKey + ) + val encrypted = encryptionUtilsV2.encryptMetadata(metadata, metadataKey) + val decrypted = encryptionUtilsV2.decryptMetadata(encrypted, metadataKey) + + assertEquals(metadata, decrypted) + } + + @Throws(Throwable::class) + @Test + fun encryptDecryptSymmetric() { + val string = "123" + val metadataKey = EncryptionUtils.generateKeyString() + + val e = EncryptionUtils.encryptStringSymmetricAsString( + string, + metadataKey.toByteArray() + ) + + val d = EncryptionUtils.decryptStringSymmetric(e, metadataKey.toByteArray()) + assertEquals(string, d) + + val encryptedMetadata = EncryptionUtils.encryptStringSymmetric( + string, + metadataKey.toByteArray(), + EncryptionUtils.ivDelimiter + ) + + val d2 = EncryptionUtils.decryptStringSymmetric( + encryptedMetadata.ciphertext, + metadataKey.toByteArray() + ) + assertEquals(string, d2) + + val decrypted = EncryptionUtils.decryptStringSymmetric( + encryptedMetadata.ciphertext, + metadataKey.toByteArray(), + encryptedMetadata.authenticationTag, + encryptedMetadata.nonce + ) + + assertEquals(string, EncryptionUtils.decodeBase64BytesToString(decrypted)) + } + + @Test + fun testEncryptDecryptUser() { + val metadataKeyBase64 = EncryptionUtils.generateKeyString() + val metadataKey = EncryptionUtils.decodeStringToBase64Bytes(metadataKeyBase64) + + val user = DecryptedUser("t1", encryptionTestUtils.t1PublicKey, null) + + val encryptedUser = encryptionUtilsV2.encryptUser(user, metadataKey) + assertNotEquals(encryptedUser.encryptedMetadataKey, metadataKeyBase64) + + val decryptedMetadataKey = encryptionUtilsV2.decryptMetadataKey(encryptedUser, encryptionTestUtils.t1PrivateKey) + val decryptedMetadataKeyBase64 = EncryptionUtils.encodeBytesToBase64String(decryptedMetadataKey) + + assertEquals(metadataKeyBase64, decryptedMetadataKeyBase64) + } + + @Throws(com.owncloud.android.operations.UploadException::class, Throwable::class) + @Test + fun testEncryptDecryptMetadataFile() { + val enc1 = MockUser("enc1", "Nextcloud") + + val root = OCFile("/") + storageManager.saveFile(root) + + val folder = OCFile("/enc/").apply { + parentId = storageManager.getFileByDecryptedRemotePath("/")?.fileId ?: throw IllegalStateException() + } + + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1.accountName, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val decrypted = encryptionUtilsV2.decryptFolderMetadataFile( + encrypted, + enc1.accountName, + enc1PrivateKey, + folder, + storageManager, + client, + 0, + signature, + user, + targetContext, + arbitraryDataProvider + ) + + // V1 doesn't have decryptedMetadataKey so that we can ignore it for comparison + for (user in decrypted.users) { + user.decryptedMetadataKey = null + } + + assertEquals(metadataFile, decrypted) + } + + @Test + fun addFile() { + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + assertEquals(2, metadataFile.metadata.files.size) + assertEquals(1, metadataFile.metadata.counter) + + val updatedMetadata = encryptionUtilsV2.addFileToMetadata( + EncryptionUtils.generateUid(), + OCFile("/test.jpg").apply { + mimeType = MimeType.JPEG + }, + EncryptionUtils.generateIV(), + // random string, not real tag + EncryptionUtils.generateUid(), + EncryptionUtils.generateKey(), + metadataFile, + storageManager + ) + + assertEquals(3, updatedMetadata.metadata.files.size) + assertEquals(2, updatedMetadata.metadata.counter) + } + + @Test + fun removeFile() { + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + assertEquals(2, metadataFile.metadata.files.size) + + val filename = metadataFile.metadata.files.keys.first() + + encryptionUtilsV2.removeFileFromMetadata(filename, metadataFile) + + assertEquals(1, metadataFile.metadata.files.size) + } + + @Test + fun renameFile() { + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + assertEquals(2, metadataFile.metadata.files.size) + + val key = metadataFile.metadata.files.keys.first() + val decryptedFile = metadataFile.metadata.files[key] + val filename = decryptedFile?.filename + val newFilename = "New File 1" + + encryptionUtilsV2.renameFile(key, newFilename, metadataFile) + + assertEquals(newFilename, metadataFile.metadata.files[key]?.filename) + assertNotEquals(filename, newFilename) + assertNotEquals(filename, metadataFile.metadata.files[key]?.filename) + } + + @Test + fun addFolder() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + assertEquals(2, metadataFile.metadata.files.size) + assertEquals(3, metadataFile.metadata.folders.size) + + val updatedMetadata = encryptionUtilsV2.addFolderToMetadata( + EncryptionUtils.generateUid(), + "new subfolder", + metadataFile, + folder, + storageManager + ) + + assertEquals(2, updatedMetadata.metadata.files.size) + assertEquals(4, updatedMetadata.metadata.folders.size) + } + + @Test + fun removeFolder() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + assertEquals(2, metadataFile.metadata.files.size) + assertEquals(3, metadataFile.metadata.folders.size) + + val encryptedFileName = EncryptionUtils.generateUid() + var updatedMetadata = encryptionUtilsV2.addFolderToMetadata( + encryptedFileName, + "new subfolder", + metadataFile, + folder, + storageManager + ) + + assertEquals(2, updatedMetadata.metadata.files.size) + assertEquals(4, updatedMetadata.metadata.folders.size) + + updatedMetadata = encryptionUtilsV2.removeFolderFromMetadata( + encryptedFileName, + updatedMetadata + ) + + assertEquals(2, updatedMetadata.metadata.files.size) + assertEquals(3, updatedMetadata.metadata.folders.size) + } + + @Test + fun verifyMetadata() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 0, signature) + + assertTrue(true) // if we reach this, test is successful + } + + private fun generateDecryptedFileV1(): com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile = + com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile().apply { + encrypted = Data().apply { + key = EncryptionUtils.generateKeyString() + filename = "Random filename.jpg" + mimetype = MimeType.JPEG + version = 1.0 + } + initializationVector = EncryptionUtils.generateKeyString() + authenticationTag = EncryptionUtils.generateKeyString() + } + + @Test + fun testMigrateDecryptedV1ToV2() { + val v1 = generateDecryptedFileV1() + val v2 = encryptionUtilsV2.migrateDecryptedFileV1ToV2(v1) + + assertEquals(v1.encrypted.filename, v2.filename) + assertEquals(v1.encrypted.mimetype, v2.mimetype) + assertEquals(v1.authenticationTag, v2.authenticationTag) + assertEquals(v1.initializationVector, v2.nonce) + assertEquals(v1.encrypted.key, v2.key) + } + + @Test + fun testMigrateMetadataV1ToV2() { + OCFile("/").apply { + storageManager.saveFile(this) + } + + val folder = OCFile("/enc/").apply { + parentId = storageManager.getFileByDecryptedRemotePath("/")?.fileId ?: throw IllegalStateException() + } + + val v1 = DecryptedFolderMetadataFileV1().apply { + metadata = com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata().apply { + metadataKeys = mapOf(Pair(0, EncryptionUtils.generateKeyString())) + } + files = mapOf( + Pair(EncryptionUtils.generateUid(), generateDecryptedFileV1()), + Pair(EncryptionUtils.generateUid(), generateDecryptedFileV1()), + Pair( + EncryptionUtils.generateUid(), + com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile().apply { + encrypted = Data().apply { + key = EncryptionUtils.generateKeyString() + filename = "subFolder" + mimetype = MimeType.WEBDAV_FOLDER + } + initializationVector = EncryptionUtils.generateKeyString() + authenticationTag = null + } + ) + ) + } + val v2 = encryptionUtilsV2.migrateV1ToV2( + v1, + enc1UserId, + enc1Cert, + folder, + storageManager + ) + + assertEquals(2, v2.metadata.files.size) + assertEquals(1, v2.metadata.folders.size) + assertEquals(1, v2.users.size) // only one user upon migration + } + + @Throws(com.owncloud.android.operations.UploadException::class, Throwable::class) + @Test + fun addSharee() { + val enc1 = MockUser("enc1", "Nextcloud") + val enc2 = MockUser("enc2", "Nextcloud") + + val root = OCFile("/") + storageManager.saveFile(root) + + val folder = OCFile("/enc/").apply { + parentId = storageManager.getFileByDecryptedRemotePath("/")?.fileId ?: throw IllegalStateException() + } + + var metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + + metadataFile = encryptionUtilsV2.addShareeToMetadata(metadataFile, enc2.accountName, enc2Cert, null) + + val encryptedMetadataFile = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + client.userId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encryptedMetadataFile) + + val decryptedByEnc1 = encryptionUtilsV2.decryptFolderMetadataFile( + encryptedMetadataFile, + enc1.accountName, + enc1PrivateKey, + folder, + storageManager, + client, + metadataFile.metadata.counter, + signature, + user, + targetContext, + arbitraryDataProvider + ) + assertEquals(metadataFile.metadata, decryptedByEnc1.metadata) + + val decryptedByEnc2 = encryptionUtilsV2.decryptFolderMetadataFile( + encryptedMetadataFile, + enc2.accountName, + enc2PrivateKey, + folder, + storageManager, + client, + metadataFile.metadata.counter, + signature, + user, + targetContext, + arbitraryDataProvider + ) + assertEquals(metadataFile.metadata, decryptedByEnc2.metadata) + } + + @Test + fun removeSharee() { + val enc1 = MockUser("enc1", "Nextcloud") + val enc2 = MockUser("enc2", "Nextcloud") + var metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + metadataFile = encryptionUtilsV2.addShareeToMetadata( + metadataFile, + enc2.accountName, + enc2Cert, + metadataFile.users.findMetadataKeyByUserId(enc2.accountName) + ) + + assertEquals(2, metadataFile.users.size) + + metadataFile = encryptionUtilsV2.removeShareeFromMetadata(metadataFile, enc2.accountName) + + assertEquals(1, metadataFile.users.size) + } + + private fun generateDecryptedFolderMetadataFile(user: User, cert: String): DecryptedFolderMetadataFile { + val metadata = DecryptedMetadata( + mutableListOf("hash1", "hash of key 2"), + false, + 1, + mutableMapOf( + Pair(EncryptionUtils.generateUid(), "Folder 1"), + Pair(EncryptionUtils.generateUid(), "Folder 2"), + Pair(EncryptionUtils.generateUid(), "Folder 3") + ), + mutableMapOf( + Pair( + EncryptionUtils.generateUid(), + DecryptedFile( + "file 1.png", + "image/png", + "initializationVector", + "authenticationTag", + "key 1" + ) + ), + Pair( + EncryptionUtils.generateUid(), + DecryptedFile( + "file 2.png", + "image/png", + "initializationVector 2", + "authenticationTag 2", + "key 2" + ) + ) + ), + EncryptionUtils.generateKey() + ) + + val users = mutableListOf( + DecryptedUser(user.accountName, cert, null) + ) + + metadata.keyChecksums.add(encryptionUtilsV2.hashMetadataKey(metadata.metadataKey)) + + return DecryptedFolderMetadataFile(metadata, users, mutableMapOf()) + } + + @Test + fun testGZip() { + val string = """ + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + This is a test. + It contains linewraps and special characters: + $$|²›³¥!’‘‘ + + """.trimIndent() + + val gzipped = encryptionUtilsV2.gZipCompress(string) + + val result = encryptionUtilsV2.gZipDecompress(gzipped) + + assertEquals(string, result) + } + + @Test + fun gunzip() { + val string = "H4sICNVkD2QAAwArycgsVgCiRIWS1OISPQDD9wZODwAAAA==" + val decoded = EncryptionUtils.decodeStringToBase64Bytes(string) + val gunzip = encryptionUtilsV2.gZipDecompress(decoded) + + assertEquals("this is a test.\n", gunzip) + } + +// @Test +// fun validate() { +// // ALEX +// val metadata1 = """{ +// "metadata": { +// "authenticationTag": "zMozev5R09UopLrq7Je1lw==", +// "ciphertext": "j0OBtUrEt4IveGiexjmGK7eKEaWrY70ZkteA5KxHDaZT/t2wwGy9j2FPQGpqXnW6OO3iAYPNgwFikI1smnfNvqdxzVDvhavl/IXa9Kg2niWyqK3D9zpz0YD6mDvl0XsOgTNVyGXNVREdWgzGEERCQoyHI1xowt/swe3KCXw+lf+XPF/t1PfHv0DiDVk70AeWGpPPPu6yggAIxB4Az6PEZhaQWweTC0an48l2FHj2MtB2PiMHtW2v7RMuE8Al3PtE4gOA8CMFrB+Npy6rKcFCXOgTZm5bp7q+J1qkhBDbiBYtvdsYujJ52Xa5SifTpEhGeWWLFnLLgPAQ8o6bXcWOyCoYfLfp4Jpft/Y7H8qzHbPewNSyD6maEv+xljjfU7hxibbszz5A4JjMdQy2BDGoTmJx7Mas+g6l6ZuHLVbdmgQOvD3waJBy6rOg0euux0Cn4bB4bIFEF2KvbhdGbY1Uiq9DYa7kEmSEnlcAYaHyroTkDg4ew7ER0vIBBMzKM3r+UdPVKKS66uyXtZc=", +// "nonce": "W+lxQJeGq7XAJiGfcDohkg==" +// }, +// "users": [{ +// "certificate": "-----BEGIN CERTIFICATE-----\nMIIDkDCCAnigAwIBAgIBADANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQGEwJERTEb\nMBkGA1UECAwSQmFkZW4tV3VlcnR0ZW1iZXJnMRIwEAYDVQQHDAlTdHV0dGdhcnQx\nEjAQBgNVBAoMCU5leHRjbG91ZDENMAsGA1UEAwwEam9objAeFw0yMzA3MTQwNzM0\nNTZaFw00MzA3MDkwNzM0NTZaMGExCzAJBgNVBAYTAkRFMRswGQYDVQQIDBJCYWRl\nbi1XdWVydHRlbWJlcmcxEjAQBgNVBAcMCVN0dXR0Z2FydDESMBAGA1UECgwJTmV4\ndGNsb3VkMQ0wCwYDVQQDDARqb2huMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEA7j3Er5YahJT0LAnSRLhpqbRo+E1AVnt98rvp3DmEfBHNzNB+DS9IBDkS\nSXM/YtfAci6Tcw8ujVBjrZX/WEmrf8ynQHxYmSaJSnP8uAT306/MceZpdpruEc9/\nS10a7vp54Zbld4NYdmfS71oVFVKgM7c/Vthx+rgu48fuxzbWAvVYLFcx47hz0DJT\nnjz2Za/R68uXpxfz7J9uEXYiqsAs/FobDsLZluT3RyywVRwKBed1EZxUeLIJiyxp\nUthhGfIb8b3Vf9jZoUVi3m5gmc4spJQHvYAkfZYHzd9ras8jBu1abQRxcu2CYnVo\n6Y0mTxhKhQS/n5gjv3ExiQF3wp/XYwIDAQABo1MwUTAdBgNVHQ4EFgQUmTeILVuB\ntv70fTGkXWGAueDp5kAwHwYDVR0jBBgwFoAUmTeILVuBtv70fTGkXWGAueDp5kAw\nDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAyVtq9XAvW7nxSW/8\nhp30z6xbzGiuviXhy/Jo91VEa8IRsWCCn3OmDFiVduTEowx76tf8clJP0gk7Pozi\n6dg/7Fin+FqQGXfCk8bLAh9gXKAikQ2GK8yRN3slRFwYC2mm23HrLdKXZHUqJcpB\nMz2zsSrOGPj1YsYOl/U8FU6KA7Yj7U3q7kDMYTAgzUPZAH+d1DISGWpZsMa0RYid\nvigCCLByiccmS/Co4Sb1esF58H+YtV5+nFBRwx881U2g2TgDKF1lPMK/y3d8B8mh\nUtW+lFxRpvyNUDpsMjOErOrtNFEYbgoUJLtqwBMmyGR+nmmh6xna331QWcRAmw0P\nnDO4ew==\n-----END CERTIFICATE-----\n", +// "encryptedMetadataKey": "HVT49bYmwXbGs/dJ2avgU9unrKnPf03MYUI5ZysSR1Bz5pqz64gzH2GBAuUJ+Q4VmHtEfcMaWW7VXgzfCQv5xLBrk+RSgcLOKnlIya8jaDlfttWxbe8jJK+/0+QVPOc6ycA/t5HNCPg09hzj+gnb2L89UHxL5accZD0iEzb5cQbGrc/N6GthjgGrgFKtFf0HhDVplUr+DL9aTyKuKLBPjrjuZbv8M6ZfXO93mOMwSZH3c3rwDUHb/KEaTR/Og4pWQmrqr1VxGLqeV/+GKWhzMYThrOZAUz+5gsbckU2M5V9i+ph0yBI5BjOZVhNuDwW8yP8WtyRJwQc+UBRei/RGBQ==", +// "userId": "john" +// }], +// "version": "2" +// } +// +// """ +// +// val signature1 = +// "ewogICAgIm1ldGFkYXRhIjogewogICAgICAgICJhdXRoZW50aWNhdGlvblRhZyI6ICJ6TW96ZXY1UjA5VW9wTHJxN0plMWx3PT0iLAogICAgICAgICJjaXBoZXJ0ZXh0IjogImowT0J0VXJFdDRJdmVHaWV4am1HSzdlS0VhV3JZNzBaa3RlQTVLeEhEYVpUL3Qyd3dHeTlqMkZQUUdwcVhuVzZPTzNpQVlQTmd3RmlrSTFzbW5mTnZxZHh6VkR2aGF2bC9JWGE5S2cybmlXeXFLM0Q5enB6MFlENm1EdmwwWHNPZ1ROVnlHWE5WUkVkV2d6R0VFUkNRb3lISTF4b3d0L3N3ZTNLQ1h3K2xmK1hQRi90MVBmSHYwRGlEVms3MEFlV0dwUFBQdTZ5Z2dBSXhCNEF6NlBFWmhhUVd3ZVRDMGFuNDhsMkZIajJNdEIyUGlNSHRXMnY3Uk11RThBbDNQdEU0Z09BOENNRnJCK05weTZyS2NGQ1hPZ1RabTVicDdxK0oxcWtoQkRiaUJZdHZkc1l1ako1MlhhNVNpZlRwRWhHZVdXTEZuTExnUEFROG82YlhjV095Q29ZZkxmcDRKcGZ0L1k3SDhxekhiUGV3TlN5RDZtYUV2K3hsampmVTdoeGliYnN6ejVBNEpqTWRReTJCREdvVG1KeDdNYXMrZzZsNlp1SExWYmRtZ1FPdkQzd2FKQnk2ck9nMGV1dXgwQ240YkI0YklGRUYyS3ZiaGRHYlkxVWlxOURZYTdrRW1TRW5sY0FZYUh5cm9Ua0RnNGV3N0VSMHZJQkJNektNM3IrVWRQVktLUzY2dXlYdFpjPSIsCiAgICAgICAgIm5vbmNlIjogIlcrbHhRSmVHcTdYQUppR2ZjRG9oa2c9PSIKICAgIH0sCiAgICAidXNlcnMiOiB7CiAgICAgICAgImNlcnRpZmljYXRlIjogIi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLVxuTUlJRGtEQ0NBbmlnQXdJQkFnSUJBREFOQmdrcWhraUc5dzBCQVFVRkFEQmhNUXN3Q1FZRFZRUUdFd0pFUlRFYlxuTUJrR0ExVUVDQXdTUW1Ga1pXNHRWM1ZsY25SMFpXMWlaWEpuTVJJd0VBWURWUVFIREFsVGRIVjBkR2RoY25ReFxuRWpBUUJnTlZCQW9NQ1U1bGVIUmpiRzkxWkRFTk1Bc0dBMVVFQXd3RWFtOW9iakFlRncweU16QTNNVFF3TnpNMFxuTlRaYUZ3MDBNekEzTURrd056TTBOVFphTUdFeEN6QUpCZ05WQkFZVEFrUkZNUnN3R1FZRFZRUUlEQkpDWVdSbFxuYmkxWGRXVnlkSFJsYldKbGNtY3hFakFRQmdOVkJBY01DVk4wZFhSMFoyRnlkREVTTUJBR0ExVUVDZ3dKVG1WNFxuZEdOc2IzVmtNUTB3Q3dZRFZRUUREQVJxYjJodU1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQlxuQ2dLQ0FRRUE3ajNFcjVZYWhKVDBMQW5TUkxocHFiUm8rRTFBVm50OThydnAzRG1FZkJITnpOQitEUzlJQkRrU1xuU1hNL1l0ZkFjaTZUY3c4dWpWQmpyWlgvV0VtcmY4eW5RSHhZbVNhSlNuUDh1QVQzMDYvTWNlWnBkcHJ1RWM5L1xuUzEwYTd2cDU0WmJsZDROWWRtZlM3MW9WRlZLZ003Yy9WdGh4K3JndTQ4ZnV4emJXQXZWWUxGY3g0N2h6MERKVFxubmp6MlphL1I2OHVYcHhmejdKOXVFWFlpcXNBcy9Gb2JEc0xabHVUM1J5eXdWUndLQmVkMUVaeFVlTElKaXl4cFxuVXRoaEdmSWI4YjNWZjlqWm9VVmkzbTVnbWM0c3BKUUh2WUFrZlpZSHpkOXJhczhqQnUxYWJRUnhjdTJDWW5Wb1xuNlkwbVR4aEtoUVMvbjVnanYzRXhpUUYzd3AvWFl3SURBUUFCbzFNd1VUQWRCZ05WSFE0RUZnUVVtVGVJTFZ1QlxudHY3MGZUR2tYV0dBdWVEcDVrQXdId1lEVlIwakJCZ3dGb0FVbVRlSUxWdUJ0djcwZlRHa1hXR0F1ZURwNWtBd1xuRHdZRFZSMFRBUUgvQkFVd0F3RUIvekFOQmdrcWhraUc5dzBCQVFVRkFBT0NBUUVBeVZ0cTlYQXZXN254U1cvOFxuaHAzMHo2eGJ6R2l1dmlYaHkvSm85MVZFYThJUnNXQ0NuM09tREZpVmR1VEVvd3g3NnRmOGNsSlAwZ2s3UG96aVxuNmRnLzdGaW4rRnFRR1hmQ2s4YkxBaDlnWEtBaWtRMkdLOHlSTjNzbFJGd1lDMm1tMjNIckxkS1haSFVxSmNwQlxuTXoyenNTck9HUGoxWXNZT2wvVThGVTZLQTdZajdVM3E3a0RNWVRBZ3pVUFpBSCtkMURJU0dXcFpzTWEwUllpZFxudmlnQ0NMQnlpY2NtUy9DbzRTYjFlc0Y1OEgrWXRWNStuRkJSd3g4ODFVMmcyVGdES0YxbFBNSy95M2Q4QjhtaFxuVXRXK2xGeFJwdnlOVURwc01qT0VyT3J0TkZFWWJnb1VKTHRxd0JNbXlHUitubW1oNnhuYTMzMVFXY1JBbXcwUFxubkRPNGV3PT1cbi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS1cbiIsCiAgICAgICAgImVuY3J5cHRlZE1ldGFkYXRhS2V5IjogIkhWVDQ5Ylltd1hiR3MvZEoyYXZnVTl1bnJLblBmMDNNWVVJNVp5c1NSMUJ6NXBxejY0Z3pIMkdCQXVVSitRNFZtSHRFZmNNYVdXN1ZYZ3pmQ1F2NXhMQnJrK1JTZ2NMT0tubEl5YThqYURsZnR0V3hiZThqSksrLzArUVZQT2M2eWNBL3Q1SE5DUGcwOWh6aitnbmIyTDg5VUh4TDVhY2NaRDBpRXpiNWNRYkdyYy9ONkd0aGpnR3JnRkt0RmYwSGhEVnBsVXIrREw5YVR5S3VLTEJQanJqdVpidjhNNlpmWE85M21PTXdTWkgzYzNyd0RVSGIvS0VhVFIvT2c0cFdRbXJxcjFWeEdMcWVWLytHS1doek1ZVGhyT1pBVXorNWdzYmNrVTJNNVY5aStwaDB5Qkk1QmpPWlZoTnVEd1c4eVA4V3R5Ukp3UWMrVUJSZWkvUkdCUT09IiwKICAgICAgICAidXNlcklkIjogImpvaG4iCiAgICB9LAogICAgInZlcnNpb24iOiAiMiIKfQo=" +// +// // TOBI +// val metadata = +// """{"metadata":{"authenticationTag":"qDcJnAAGtGDlHWiQMBfXgw\u003d\u003d","ciphertext":"3zUhwIgJWMB7DvrbsDaMvh8MbJdoTxL0OMPCCdYSfBt7gB+V/hwqelL1IOaLto3avhHGSebnrotF06iEP/jZwWg9hApIPTHc8B4XTOY0/kezqYyVqTyquTUZpDpqgVAheQskZZ8I4Ir0seajUkt4KtVRfzO6v8CePRrEg6uKwdYsqDcJnAAGtGDlHWiQMBfXgw\u003d\u003d|4hbOyn1ykQL+9D6SnPY3cQ\u003d\u003d","nonce":"4hbOyn1ykQL+9D6SnPY3cQ\u003d\u003d"},"users":[{"certificate":"-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIBADANBgkqhkiG9w0BAQUFADANMQswCQYDVQQDDAJ0MTAe\nFw0yMzA3MjUwNzU3MTJaFw00MzA3MjAwNzU3MTJaMA0xCzAJBgNVBAMMAnQxMIIB\nIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtafHmDBcBqIu4HmMxMDW3j0S\ny+S0YaKwHnBRt85KSwcEov0B5FOLuLknoBGx4Dn3u93ilThXXxacPMHeXL7WPuAs\n21/G7vsqwvrRRnCduf+FUO/AZeDCNErzpsQ8LmTa4PUloLPUcImpSjrHwhMs9Ekv\nEbLRjbeSmSp9XvM+1fV/3jkT5jkOSnCFx5TGwGN5uHqwUir4UWXasvg253NK2XmW\nipKCDCR9TmH1baP3pNdoiChdmErT1c6E4DbBXpTw8XgP5ZbYH+qg1UQ/hC8nRJ3D\nyCcHL+dg/GYraBMhDn4w2Vvq77xNNoNWQ9cT5Ay6cJbQLBQoJQirygQFrobYRQID\nAQABo1MwUTAdBgNVHQ4EFgQUE9zCeA9/QMAtVgLxD23X6ZcodhMwHwYDVR0jBBgw\nFoAUE9zCeA9/QMAtVgLxD23X6ZcodhMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG\n9w0BAQUFAAOCAQEAZdy/YjJlvnz3FQwxp6oVtMJccpdxveEPfLzgaverhtd/vP8O\nAvDzOLgQJHmrDS91SG503eU4cYGyuNKwd77OyTnqMg+GUEmJhGfPpSVrEIdh65jv\nq61T4oqBdehevVmBq54rGiwL0DGv1DlXQlwiJZP4qni2KnOEFcnvL3gVtRnQjXQ+\nkHvlMshkK6w021EMV5NfjG2zg67wC65rLaej5f6Ssp2S7g2VtmE4aXq1bjAuEbqk\n4TiyZHLDdsJuqzyGyyOpMV7i9ucXDoaZt9cGS9hT2vRxTrSH63vKR8Xeig9+stLw\nt9ONcUqCKP7hd8rajtxM4JIIRExwD8OkgARWGg\u003d\u003d\n-----END CERTIFICATE-----\n","encryptedMetadataKey":"s4kDkkLpk1mSmXedP7huiCNC4DYmDAmA2VYGem5M8jIGPC6miVQoo4WXZrEBhdsLw7Msf5iT3A3fTaHhwsI8Jf4McsFyM9/FXT1mCEaGOEpNjbKOlJY1uPUFNOhLqUfFiBos6oBT53hWwoXWjytYvLBbXuXY5YLOysjgBh6URrgFUZAJAmcOJ6OFKgfIIthoqkQc7CQUY97VsRzAXzeYTANBc2yW1pSN51HqftvMzvewFRsJQLcu7a9NjpTdG9LiLhn5eLXOLymXEE/aaPHKXeprlXLzrdWU1xwZRJqV+to2FEiH6CQNsO4+9h5m0VjXekiNeAFrsXB5cJgUipGuzQ\u003d\u003d","userId":"t1"}],"version":"2.0"}""" +// +// val base = EncryptionUtils.encodeStringToBase64String(metadata) +// +// val signature = +// "MIAGCSqGSIb3DQEHAqCAMIACAQExDTALBglghkgBZQMEAgEwCwYJKoZIhvcNAQcBoIAwggLoMIIB0KADAgECAgEAMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAMMAnQxMB4XDTIzMDcyNTA3NTcxMloXDTQzMDcyMDA3NTcxMlowDTELMAkGA1UEAwwCdDEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC1p8eYMFwGoi7geYzEwNbePRLL5LRhorAecFG3zkpLBwSi/QHkU4u4uSegEbHgOfe73eKVOFdfFpw8wd5cvtY+4CzbX8bu+yrC+tFGcJ25/4VQ78Bl4MI0SvOmxDwuZNrg9SWgs9RwialKOsfCEyz0SS8RstGNt5KZKn1e8z7V9X/eORPmOQ5KcIXHlMbAY3m4erBSKvhRZdqy+Dbnc0rZeZaKkoIMJH1OYfVto/ek12iIKF2YStPVzoTgNsFelPDxeA/lltgf6qDVRD+ELydEncPIJwcv52D8ZitoEyEOfjDZW+rvvE02g1ZD1xPkDLpwltAsFCglCKvKBAWuhthFAgMBAAGjUzBRMB0GA1UdDgQWBBQT3MJ4D39AwC1WAvEPbdfplyh2EzAfBgNVHSMEGDAWgBQT3MJ4D39AwC1WAvEPbdfplyh2EzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBl3L9iMmW+fPcVDDGnqhW0wlxyl3G94Q98vOBq96uG13+8/w4C8PM4uBAkeasNL3VIbnTd5ThxgbK40rB3vs7JOeoyD4ZQSYmEZ8+lJWsQh2HrmO+rrVPiioF16F69WYGrnisaLAvQMa/UOVdCXCIlk/iqeLYqc4QVye8veBW1GdCNdD6Qe+UyyGQrrDTbUQxXk1+MbbODrvALrmstp6Pl/pKynZLuDZW2YThperVuMC4RuqThOLJkcsN2wm6rPIbLI6kxXuL25xcOhpm31wZL2FPa9HFOtIfre8pHxd6KD36y0vC3041xSoIo/uF3ytqO3EzgkghETHAPw6SABFYaAAAxggHUMIIB0AIBATASMA0xCzAJBgNVBAMMAnQxAgEAMAsGCWCGSAFlAwQCAaCBljAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yMzA3MjgwNzMwMTJaMCsGCSqGSIb3DQEJNDEeMBwwCwYJYIZIAWUDBAIBoQ0GCSqGSIb3DQEBCwUAMC8GCSqGSIb3DQEJBDEiBCAx7RTJg7hbY5Mkzjw3f6qhX7k/J0FdVz2cL3ow0AmyYjANBgkqhkiG9w0BAQsFAASCAQAbUmb9e7eoIcPNzDSmnzbrueBzgT8YszNGEI+1YCq8XdWN4kDztvP1ZNV21VCO6BvcbfUAnXXgcX5BPeLZNsgXPj3c8TbD59GQl3oT/tIchgMsA20RdAtIwvItlZKh+X6sp0OHkRPYSk/mEYKCKPqrKdJicRWex8ItCwpDR91KSOiKJrN/+DKOGG0sVI9gjzbtrHsN8HmVKxOoNV+wwipcLsWsEmuV+wvPCQ9HJidLX9Q17Bgfc+qJg19aB6iKLWPhjgnfpKGbK5VJuQTdDWPUJ2O4G3W/iwxJ0hAJ7tks4zIATmgGzhgTWYx5LVXbKcuL04xhIOjqwedHeCSBZSSaAAAAAAAA" +// +// val metadataFile = EncryptionUtils.deserializeJSON( +// metadata, +// object : TypeToken() {} +// ) +// assertNotNull(metadataFile) +// +// val certJohnString = metadataFile.users[0].certificate +// val certJohn = EncryptionUtils.convertCertFromString(certJohnString) +// +// val t1String = """-----BEGIN CERTIFICATE----- +// MIIC6DCCAdCgAwIBAgIBADANBgkqhkiG9w0BAQUFADANMQswCQYDVQQDDAJ0MTAe +// Fw0yMzA3MjUwNzU3MTJaFw00MzA3MjAwNzU3MTJaMA0xCzAJBgNVBAMMAnQxMIIB +// IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtafHmDBcBqIu4HmMxMDW3j0S +// y+S0YaKwHnBRt85KSwcEov0B5FOLuLknoBGx4Dn3u93ilThXXxacPMHeXL7WPuAs +// 21/G7vsqwvrRRnCduf+FUO/AZeDCNErzpsQ8LmTa4PUloLPUcImpSjrHwhMs9Ekv +// EbLRjbeSmSp9XvM+1fV/3jkT5jkOSnCFx5TGwGN5uHqwUir4UWXasvg253NK2XmW +// ipKCDCR9TmH1baP3pNdoiChdmErT1c6E4DbBXpTw8XgP5ZbYH+qg1UQ/hC8nRJ3D +// yCcHL+dg/GYraBMhDn4w2Vvq77xNNoNWQ9cT5Ay6cJbQLBQoJQirygQFrobYRQID +// AQABo1MwUTAdBgNVHQ4EFgQUE9zCeA9/QMAtVgLxD23X6ZcodhMwHwYDVR0jBBgw +// FoAUE9zCeA9/QMAtVgLxD23X6ZcodhMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG +// 9w0BAQUFAAOCAQEAZdy/YjJlvnz3FQwxp6oVtMJccpdxveEPfLzgaverhtd/vP8O +// AvDzOLgQJHmrDS91SG503eU4cYGyuNKwd77OyTnqMg+GUEmJhGfPpSVrEIdh65jv +// q61T4oqBdehevVmBq54rGiwL0DGv1DlXQlwiJZP4qni2KnOEFcnvL3gVtRnQjXQ+ +// kHvlMshkK6w021EMV5NfjG2zg67wC65rLaej5f6Ssp2S7g2VtmE4aXq1bjAuEbqk +// 4TiyZHLDdsJuqzyGyyOpMV7i9ucXDoaZt9cGS9hT2vRxTrSH63vKR8Xeig9+stLw +// t9ONcUqCKP7hd8rajtxM4JIIRExwD8OkgARWGg== +// -----END CERTIFICATE-----""" +// +// val t1cert = EncryptionUtils.convertCertFromString(t1String) +// val t1PrivateKeyKey = EncryptionUtils.PEMtoPrivateKey(encryptionTestUtils.t1PrivateKey) +// +// // val signed = encryptionUtilsV2.getMessageSignature( +// // t1cert, +// // t1PrivateKeyKey, +// // metadataFile +// // ) +// +// assertTrue(encryptionUtilsV2.verifySignedMessage(signature1, metadata1, listOf(certJohn, t1cert))) +// } + + @Throws(Throwable::class) + @Test + fun testSigning() { + val metadata = + """{"metadata": {"authenticationTag": "zMozev5R09UopLrq7Je1lw==","ciphertext": "j0OBtUrEt4IveGiexjm + |GK7eKEaWrY70ZkteA5KxHDaZT/t2wwGy9j2FPQGpqXnW6OO3iAYPNgwFikI1smnfNvqdxzVDvhavl/IXa9Kg2niWyqK3D9 + |zpz0YD6mDvl0XsOgTNVyGXNVREdWgzGEERCQoyHI1xowt/swe3KCXw+lf+XPF/t1PfHv0DiDVk70AeWGpPPPu6yggAIxB4 + |Az6PEZhaQWweTC0an48l2FHj2MtB2PiMHtW2v7RMuE8Al3PtE4gOA8CMFrB+Npy6rKcFCXOgTZm5bp7q+J1qkhBDbiBYtv + |dsYujJ52Xa5SifTpEhGeWWLFnLLgPAQ8o6bXcWOyCoYfLfp4Jpft/Y7H8qzHbPewNSyD6maEv+xljjfU7hxibbszz5A4Jj + |MdQy2BDGoTmJx7Mas+g6l6ZuHLVbdmgQOvD3waJBy6rOg0euux0Cn4bB4bIFEF2KvbhdGbY1Uiq9DYa7kEmSEnlcAYaHyr + |oTkDg4ew7ER0vIBBMzKM3r+UdPVKKS66uyXtZc=","nonce": "W+lxQJeGq7XAJiGfcDohkg=="},"users": [{"cert + |ificate": "-----BEGIN CERTIFICATE-----\nMIIDkDCCAnigAwIBAgIBADANBgkqhkiG9w0BAQUFADBhMQswCQYDVQ + |QGEwJERTEb\nMBkGA1UECAwSQmFkZW4tV3VlcnR0ZW1iZXJnMRIwEAYDVQQHDAlTdHV0dGdhcnQx\nEjAQBgNVBAoMCU5l + |eHRjbG91ZDENMAsGA1UEAwwEam9objAeFw0yMzA3MTQwNzM0\nNTZaFw00MzA3MDkwNzM0NTZaMGExCzAJBgNVBAYTAkRF + |MRswGQYDVQQIDBJCYWRl\nbi1XdWVydHRlbWJlcmcxEjAQBgNVBAcMCVN0dXR0Z2FydDESMBAGA1UECgwJTmV4\ndGNsb3 + |VkMQ0wCwYDVQQDDARqb2huMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEA7j3Er5YahJT0LAnSRLhpqbRo+E + |1AVnt98rvp3DmEfBHNzNB+DS9IBDkS\nSXM/YtfAci6Tcw8ujVBjrZX/WEmrf8ynQHxYmSaJSnP8uAT306/MceZpdpruEc + |9/\nS10a7vp54Zbld4NYdmfS71oVFVKgM7c/Vthx+rgu48fuxzbWAvVYLFcx47hz0DJT\nnjz2Za/R68uXpxfz7J9uEXYi + |qsAs/FobDsLZluT3RyywVRwKBed1EZxUeLIJiyxp\nUthhGfIb8b3Vf9jZoUVi3m5gmc4spJQHvYAkfZYHzd9ras8jBu1a + |bQRxcu2CYnVo\n6Y0mTxhKhQS/n5gjv3ExiQF3wp/XYwIDAQABo1MwUTAdBgNVHQ4EFgQUmTeILVuB\ntv70fTGkXWGAue + |Dp5kAwHwYDVR0jBBgwFoAUmTeILVuBtv70fTGkXWGAueDp5kAw\nDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAA + |OCAQEAyVtq9XAvW7nxSW/8\nhp30z6xbzGiuviXhy/Jo91VEa8IRsWCCn3OmDFiVduTEowx76tf8clJP0gk7Pozi\n6dg/ + |7Fin+FqQGXfCk8bLAh9gXKAikQ2GK8yRN3slRFwYC2mm23HrLdKXZHUqJcpB\nMz2zsSrOGPj1YsYOl/U8FU6KA7Yj7U3q + |7kDMYTAgzUPZAH+d1DISGWpZsMa0RYid\nvigCCLByiccmS/Co4Sb1esF58H+YtV5+nFBRwx881U2g2TgDKF1lPMK/y3d8 + |B8mh\nUtW+lFxRpvyNUDpsMjOErOrtNFEYbgoUJLtqwBMmyGR+nmmh6xna331QWcRAmw0P\nnDO4ew==\n-----END CER + |TIFICATE-----\n","encryptedMetadataKey": "HVT49bYmwXbGs/dJ2avgU9unrKnPf03MYUI5ZysSR1Bz5pqz64gz + |H2GBAuUJ+Q4VmHtEfcMaWW7VXgzfCQv5xLBrk+RSgcLOKnlIya8jaDlfttWxbe8jJK+/0+QVPOc6ycA/t5HNCPg09hzj+g + |nb2L89UHxL5accZD0iEzb5cQbGrc/N6GthjgGrgFKtFf0HhDVplUr+DL9aTyKuKLBPjrjuZbv8M6ZfXO93mOMwSZH3c3rw + |DUHb/KEaTR/Og4pWQmrqr1VxGLqeV/+GKWhzMYThrOZAUz+5gsbckU2M5V9i+ph0yBI5BjOZVhNuDwW8yP8WtyRJwQc+UB + |Rei/RGBQ==","userId": "john"}],"version": "2"} + """.trimMargin() + + val privateKey = EncryptionUtils.PEMtoPrivateKey(encryptionTestUtils.t1PrivateKey) + val certificateT1 = EncryptionUtils.convertCertFromString(encryptionTestUtils.t1PublicKey) + val certificateEnc2 = EncryptionUtils.convertCertFromString(enc2Cert) + + val signed = encryptionUtilsV2.signMessage( + certificateT1, + privateKey, + metadata + ) + + val certs = listOf( + certificateEnc2, + certificateT1 + ) + + assertTrue(encryptionUtilsV2.verifySignedData(signed, certs)) + } + + @Throws(Throwable::class) + @Test + fun sign() { + val sut = "randomstring123" + + val privateKey = EncryptionUtils.PEMtoPrivateKey(encryptionTestUtils.t1PrivateKey) + val certificate = EncryptionUtils.convertCertFromString(encryptionTestUtils.t1PublicKey) + + val signed = encryptionUtilsV2.signMessage( + certificate, + privateKey, + sut + ) + + val certs = listOf( + EncryptionUtils.convertCertFromString(enc2Cert), + certificate + ) + + assertTrue(encryptionUtilsV2.verifySignedData(signed, certs)) + } + + @Test + @Throws(Exception::class) + fun testUpdateFileNameForEncryptedFile() { + val folder = testFolder() + + val metadata = EncryptionTestUtils().generateFolderMetadataV2( + client.userId, + EncryptionTestIT.publicKey + ) + + RefreshFolderOperation.updateFileNameForEncryptedFile(storageManager, metadata, folder) + + assertEquals(folder.decryptedRemotePath.contains("null"), false) + } + + /** + * DecryptedFolderMetadata -> EncryptedFolderMetadata -> JSON -> encrypt -> decrypt -> JSON -> + * EncryptedFolderMetadata -> DecryptedFolderMetadata + */ + @Test + @Throws(Exception::class, Throwable::class) + fun encryptionMetadataV2() { + val decryptedFolderMetadata1: DecryptedFolderMetadataFile = + EncryptionTestUtils().generateFolderMetadataV2(client.userId, EncryptionTestIT.publicKey) + val root = OCFile("/") + storageManager.saveFile(root) + + val folder = OCFile("/enc") + folder.parentId = storageManager.getFileByDecryptedRemotePath("/")?.fileId ?: throw IllegalStateException() + + storageManager.saveFile(folder) + + decryptedFolderMetadata1.filedrop.clear() + + // encrypt + val encryptedFolderMetadata1 = encryptionUtilsV2.encryptFolderMetadataFile( + decryptedFolderMetadata1, + client.userId, + folder, + storageManager, + client, + EncryptionTestIT.publicKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encryptedFolderMetadata1) + + // serialize + val encryptedJson = EncryptionUtils.serializeJSON(encryptedFolderMetadata1, true) + + // de-serialize + val encryptedFolderMetadata2 = EncryptionUtils.deserializeJSON( + encryptedJson, + object : TypeToken() {} + ) + + // decrypt + val decryptedFolderMetadata2 = encryptionUtilsV2.decryptFolderMetadataFile( + encryptedFolderMetadata2!!, + getUserId(user), + EncryptionTestIT.privateKey, + folder, + fileDataStorageManager, + client, + decryptedFolderMetadata1.metadata.counter, + signature, + user, + targetContext, + arbitraryDataProvider + ) + + // V1 doesn't have decryptedMetadataKey so that we can ignore it for comparison + for (user in decryptedFolderMetadata2.users) { + user.decryptedMetadataKey = null + } + + // compare + assertTrue( + EncryptionTestIT.compareJsonStrings( + EncryptionUtils.serializeJSON(decryptedFolderMetadata1), + EncryptionUtils.serializeJSON(decryptedFolderMetadata2) + ) + ) + } + + @Throws(Throwable::class) + @Test + fun decryptFiledropV2() { + val sut = EncryptedFiledrop( + """QE5nJmA8QC3rBJxbpsZu6MvkomwHMKTYf/3dEz9Zq3ITHLK/wNAIqWTbDehBJ7SlTfXakkKR9o0sOkUDI7PD8qJyv5hW7LzifszYGe + |xE0V1daFcCFApKrIEBABHVOq+ZHJd8IzNSz3hdA9bWd2eiaEGyQzgdTPELE6Ie84IwFANJHcaRB5B43aaDdbUXNJ4/oMboOReKTJ + |/vT6ZGhve4DRPEsez0quyDZDNlin5hD6UaUzw= + """.trimMargin(), + "HC87OgVzbR2CXdWp7rKI5A==", + "7PSq7INkM2WKfmEPpRpTPA==", + listOf( + EncryptedFiledropUser( + "android3", + """cNzk8cNyoTJ49Cj/x2WPlsMAnUWlZsfnKJ3VIRiczASeUYUFhaJpD8HDWE0uhkXSD7i9nzpe6pR7zllE7UE/QniDd+BQiF + |80E5fSO1KVfFkLZRT+2pX5oPnl4CVtMnxb4xG7J1nAUqMhfS8PtQIr0+S7NKDdrUc41aNOB/4kH0D9LSo/bSC38L7ewv + |mISM6ZFi1bfI1505kZV0HqcW12nZwHwe3s6rYkoSPBOPX1oPkvMYTVLkYuU+7DNL4HW7D9dc9X4bsSGLdj4joRi9FURi + |mMv6MOrWOnYlX2zmMKAF3nEjLlhngKG7pUi/qMIlft2AhRM4cJuuIQ29vvTGFFDQ== + """.trimMargin() + ) + ) + ) + + val privateKey = + """MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDPNCnYcPgGQwCzL8sxLTiE0atn5tiW1nfPQc1aY/+aXvkpF4h2vT + |S/hQg2SCNFUlw8aYKksk5IH5FFcPv9QFG/TQDQOnZhi7moVPnVwLkx+cDfWQQs1EOhI/ZPdSo7MdaRLttbZZs/GJfnr1ziYZTxLO + |UUxT541cnSpqGTKmUXhGMoX+jQTmcn1NyBD537NdetOxSdMfvBIobjRQ70/9c1HFGQSrJa+DmPiis6iFkd1LH6WWRbreC6DsRSqK + |ne3sD1ujx39k+VxtBe035c2L9PbTMMW3kBdZxlRkV1tUQhDAys0K+CyvNIFsOjqvQKTnXNfWO+kVnpOkpbTK4imuPbAgMBAAECgf + |9T537U/6TuwJLSj4bfYev8qYaakfVIpyMkL33e4YBQnUzhlCPBVYgpHkDPwznk2XhjQQiVcRAycmUHBmy4aPkcOjuBmd87aTj03k + |niDk+doFDNU8myuwWTw/1fHdElRnLyZxEKrb391HD4SVVQMuxnw8UoC4iNcPnYneY/GTiTtB3dVcRKdabX3Oak2TFiJyJBtTz4RN + |sRYVXM3jyCbxj8uV+XNr+3OuQe5u7cV5gkXOXHqcNczOrxGzSXVGULuw8FiHIlhId7tot3dGdyVvWD9YIwwGA/9/3g8JixqpQHKZ + |6YJAeqltydisGa3CIIEzBAh52GJC7yzMKSC2ZAtW0CgYEA6B/O+EgtZthiXOwivqZmKKGgWGLSOGjVsExSa1iiTTz3EFwcdD54mU + |TKc6hw787NFlfN1m7B7EDQxIldRDI3One1q2dj87taco/qFqKsHuAuC3gmZIp2F4l2P8NpdHHFMzUzsfs+grY/wLHZiJdfOTdulA + |s9go5mDloMC96n0/UCgYEA5IQo7c4ZxwhlssIn89XaOlKGoIct07wsBMu47HZYFqgG2/NUN8zRfSdSvot+6zinAb6Z3iGZ2FBL+C + |MmoEMGwuXSQjWxeD//UU6V5AZqlgis5s9WakKWmkTkVV3bPSwW0DuNcqbMk7BxAXcQ6QGIiBtzeaPuL/3gzA9e9vm8xo8CgYEAqL + |I9S6nA/UZzLg8bLS1nf03/Z1ziZMajzk2ZdJRk1/dfow8eSskAAnvBGo8nDNFhsUQ8vwOdgeKVFtCx7JcGFkLbz+cC+CaIFExNFw + |hASOwp6oH2fQk3y+FGBA8ze8IXTCD1IftzMbHb4WIfsyo3tTB497S3jkOJHhMJQDMgC2UCgYEAzjUgRe98vWkrdFLWAKfSxFxiFg + |vF49JjGnTHy8HDHbbEccizD6NoyvooJb/1aMd3lRBtAtDpZhSXaTQ3D9lMCaWfxZV0LyH5AGLcyaasmfT8KU+iGEM8abuPHCWUyC + |+36nJC4tn3s7I9V2gdP1Xd4Yx7+KFgN7huGVYpiM61dasCgYAQs5mPHRBeU+BHtPRyaLHhYq+jjYeocwyOpfw5wkiH3jsyUWTK9+ + |GlAoV75SYvQVIQS0VH1C1/ajz9yV02frAaUXbGtZJbyeAcyy3DjCc7iF0swJ4slP3gGVJipVF4aQ0d9wMoJ7SBaaTR0ohXeUWmTT + |X+VGf+cZQ2IefKVnz9mg== + """.trimMargin() + + val decryptedFile = EncryptionUtilsV2().decryptFiledrop(sut, privateKey, arbitraryDataProvider, user) + assertEquals("test.txt", decryptedFile.filename) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/EspressoIdlingResource.kt b/app/src/androidTest/java/com/owncloud/android/utils/EspressoIdlingResource.kt new file mode 100644 index 000000000000..0b05ee91e10b --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/EspressoIdlingResource.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.utils + +import androidx.test.espresso.idling.CountingIdlingResource + +object EspressoIdlingResource { + + private const val RESOURCE = "GLOBAL" + + @JvmField val countingIdlingResource = CountingIdlingResource(RESOURCE) + + fun increment() { + countingIdlingResource.increment() + } + + fun decrement() { + if (!countingIdlingResource.isIdleNow) { + countingIdlingResource.decrement() + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileExportUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileExportUtilsIT.kt new file mode 100644 index 000000000000..2aa84ed5181c --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/FileExportUtilsIT.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class FileExportUtilsIT : AbstractIT() { + @Test + fun exportFile() { + val file = createFile("export.txt", 10) + + val sut = FileExportUtils() + + val expectedFile = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + File("/sdcard/Downloads/export.txt") + } else { + File("/storage/emulated/0/Download/export.txt") + } + + assertFalse(expectedFile.exists()) + + sut.exportFile("export.txt", "/text/plain", targetContext.contentResolver, null, file) + + assertTrue(expectedFile.exists()) + assertEquals(file.length(), expectedFile.length()) + assertTrue(expectedFile.delete()) + } + + @Test + fun exportOCFile() { + val file = createFile("export.txt", 10) + val ocFile = OCFile("/export.txt").apply { + storagePath = file.absolutePath + } + + val sut = FileExportUtils() + + val expectedFile = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + File("/sdcard/Downloads/export.txt") + } else { + File("/storage/emulated/0/Download/export.txt") + } + + assertFalse(expectedFile.exists()) + + sut.exportFile("export.txt", "/text/plain", targetContext.contentResolver, ocFile, null) + + assertTrue(expectedFile.exists()) + assertEquals(file.length(), expectedFile.length()) + assertTrue(expectedFile.delete()) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt new file mode 100644 index 000000000000..04b5dba55ae9 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt @@ -0,0 +1,146 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.FileStorageUtils.checkIfEnoughSpace +import com.owncloud.android.utils.FileStorageUtils.pathToUserFriendlyDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class FileStorageUtilsIT : AbstractIT() { + private fun openFile(name: String): File { + val ctx: Context = ApplicationProvider.getApplicationContext() + val externalFilesDir = ctx.getExternalFilesDir(null) + return File(externalFilesDir, name) + } + + @Test + @SuppressWarnings("MagicNumber") + fun testEnoughSpaceWithoutLocalFile() { + val ocFile = OCFile("/test.txt") + val file = openFile("test.txt") + file.createNewFile() + + ocFile.storagePath = file.absolutePath + + ocFile.fileLength = 100 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + + ocFile.fileLength = 0 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + + ocFile.fileLength = 100 + assertFalse(checkIfEnoughSpace(50L, ocFile)) + + ocFile.fileLength = 100 + assertFalse(checkIfEnoughSpace(100L, ocFile)) + } + + @Test + @SuppressWarnings("MagicNumber") + fun testEnoughSpaceWithLocalFile() { + val ocFile = OCFile("/test.txt") + val file = openFile("test.txt") + file.writeText("123123") + + ocFile.storagePath = file.absolutePath + + ocFile.fileLength = 100 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + + ocFile.fileLength = 0 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + + ocFile.fileLength = 100 + assertFalse(checkIfEnoughSpace(50L, ocFile)) + + ocFile.fileLength = 100 + assertFalse(checkIfEnoughSpace(100L, ocFile)) + } + + @Test + @SuppressWarnings("MagicNumber") + fun testEnoughSpaceWithoutLocalFolder() { + val ocFile = OCFile("/test/") + val file = openFile("test") + File(file, "1.txt").writeText("123123") + + ocFile.storagePath = file.absolutePath + + ocFile.fileLength = 100 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + + ocFile.fileLength = 0 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + + ocFile.fileLength = 100 + assertFalse(checkIfEnoughSpace(50L, ocFile)) + + ocFile.fileLength = 100 + assertFalse(checkIfEnoughSpace(100L, ocFile)) + } + + @Test + @SuppressWarnings("MagicNumber") + fun testEnoughSpaceWithLocalFolder() { + val ocFile = OCFile("/test/") + val folder = openFile("test") + folder.mkdirs() + val file = File(folder, "1.txt") + file.createNewFile() + file.writeText("123123") + + ocFile.storagePath = folder.absolutePath + ocFile.mimeType = "DIR" + + ocFile.fileLength = 100 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + + ocFile.fileLength = 0 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + + ocFile.fileLength = 100 + assertFalse(checkIfEnoughSpace(50L, ocFile)) + + ocFile.fileLength = 44 + assertTrue(checkIfEnoughSpace(50L, ocFile)) + + ocFile.fileLength = 100 + assertTrue(checkIfEnoughSpace(100L, ocFile)) + } + + @Test + @SuppressWarnings("MagicNumber") + fun testEnoughSpaceWithNoLocalFolder() { + val ocFile = OCFile("/test/") + + ocFile.mimeType = "DIR" + + ocFile.fileLength = 100 + assertTrue(checkIfEnoughSpace(200L, ocFile)) + } + + @Test + fun testPathToUserFriendlyDisplay() { + assertEquals("/", pathToUserFriendlyDisplay("/")) + assertEquals("/sdcard/", pathToUserFriendlyDisplay("/sdcard/")) + assertEquals("/sdcard/test/1/", pathToUserFriendlyDisplay("/sdcard/test/1/")) + assertEquals("Internal storage/Movies/", pathToUserFriendlyDisplay("/storage/emulated/0/Movies/")) + assertEquals("Internal storage/", pathToUserFriendlyDisplay("/storage/emulated/0/")) + } + + private fun pathToUserFriendlyDisplay(path: String): String = + pathToUserFriendlyDisplay(path, targetContext, targetContext.resources) +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileUploadHelperTest.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileUploadHelperTest.kt new file mode 100644 index 000000000000..502eb923526b --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/FileUploadHelperTest.kt @@ -0,0 +1,538 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils + +import com.nextcloud.client.database.dao.UploadDao +import com.nextcloud.client.database.entity.UploadEntity +import com.nextcloud.client.database.entity.toOCUpload +import com.nextcloud.client.database.entity.toUploadEntity +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.utils.extensions.checkWCFRestrictions +import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.resources.status.OCCapability +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@Suppress("TooManyFunctions") +class FileUploadHelperTest { + + private lateinit var uploadDao: UploadDao + private lateinit var fileUploadHelper: FileUploadHelper + + private val accountName = "test@nextcloud.example.com" + private val localPath = "/sdcard/DCIM/photo.jpg" + + private fun toggleWCFRestrictions(value: Boolean) { + val mockCapability = mockk(relaxed = true) + every { mockCapability.checkWCFRestrictions() } returns value + + val mockFileStorageManager = mockk(relaxed = true) + every { mockFileStorageManager.getCapability(any()) } returns mockCapability + + val field = FileUploadHelper::class.java.getDeclaredField("fileStorageManager") + field.isAccessible = true + field.set(fileUploadHelper, mockFileStorageManager) + } + + @Suppress("LongParameterList") + private fun buildEntity( + id: Int = 1, + localPath: String = this.localPath, + remotePath: String = "/remote/path/photo.jpg", + accountName: String = this.accountName, + status: Int = UploadStatus.UPLOAD_IN_PROGRESS.value, + lastResult: Int = UploadResult.UNKNOWN.value, + nameCollisionPolicy: Int = NameCollisionPolicy.DEFAULT.serialize(), + fileSize: Long = 1024L, + isWifiOnly: Int = 0, + isWhileChargingOnly: Int = 0, + isCreateRemoteFolder: Int = 1, + createdBy: Int = 0, + folderUnlockToken: String? = null, + uploadEndTimestampLong: Long? = null + ) = UploadEntity( + id = id, + localPath = localPath, + remotePath = remotePath, + accountName = accountName, + fileSize = fileSize, + status = status, + localBehaviour = 0, + uploadTime = null, + nameCollisionPolicy = nameCollisionPolicy, + isCreateRemoteFolder = isCreateRemoteFolder, + uploadEndTimestamp = 0, + uploadEndTimestampLong = uploadEndTimestampLong, + lastResult = lastResult, + isWhileChargingOnly = isWhileChargingOnly, + isWifiOnly = isWifiOnly, + createdBy = createdBy, + folderUnlockToken = folderUnlockToken + ) + + private fun buildOCUpload( + localPath: String = this.localPath, + remotePath: String = "/remote/path/photo.jpg", + accountName: String = this.accountName + ): OCUpload = OCUpload(localPath, remotePath, accountName).apply { + uploadId = 1L + fileSize = 1024L + uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS + nameCollisionPolicy = NameCollisionPolicy.DEFAULT + isCreateRemoteFolder = true + localAction = 0 + isUseWifiOnly = false + isWhileChargingOnly = false + lastResult = UploadResult.UNKNOWN + createdBy = 0 + folderUnlockToken = null + } + + @Before + fun setUp() { + uploadDao = mockk(relaxed = true) + fileUploadHelper = spyk(FileUploadHelper(), recordPrivateCalls = true) + val uploadsStorageManager = mockk(relaxed = true) + + val daoField = com.owncloud.android.datamodel.UploadsStorageManager::class.java + .declaredFields + .first { it.type == UploadDao::class.java } + daoField.isAccessible = true + daoField.set(uploadsStorageManager, uploadDao) + + val field = FileUploadHelper::class.java.getDeclaredField("uploadsStorageManager") + field.isAccessible = true + field.set(fileUploadHelper, uploadsStorageManager) + } + + @Test + fun getUploadByPathsExactMatch() { + val remotePath = "/remote/path/photo.jpg" + val entity = buildEntity(remotePath = remotePath) + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, remotePath) } returns entity + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, remotePath) + + assertNotNull(result) + assertEquals(remotePath, result?.remotePath) + verify(exactly = 1) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, remotePath) } + + // alternative path should NOT be queried when exact match found + verify(exactly = 0) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, "/remote/path/photo.JPG") } + } + + @Test + fun getUploadByPathsCaseInsensitiveExtensionFallback() { + toggleWCFRestrictions(true) + + // DB stores "/a/b/1.TXT", caller searches with "/a/b/1.txt" + val searchPath = "/a/b/AAA/b/1.txt" + val storedPath = "/a/b/AAA/b/1.TXT" + val entity = buildEntity(remotePath = storedPath) + + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } returns null + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } returns entity + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, searchPath) + + assertNotNull(result) + assertEquals(storedPath, result?.remotePath) + verify(exactly = 1) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } + verify(exactly = 1) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } + } + + @Test + fun getUploadByPathsFindsRecordWhenDBHasLowercaseExtensionButSearchUsesUppercase() { + toggleWCFRestrictions(true) + + // DB stores "/a/b/1.txt", caller searches with "/a/b/1.TXT" + val searchPath = "/a/b/AAA/b/1.TXT" + val storedPath = "/a/b/AAA/b/1.txt" + val entity = buildEntity(remotePath = storedPath) + + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } returns null + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } returns entity + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, searchPath) + + assertNotNull(result) + assertEquals(storedPath, result?.remotePath) + } + + @Test + fun getUploadByPathsReturnsNullWhenNeitherExactNorAlternativeExtensionPathExists() { + val remotePath = "/a/b/1.jpg" + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, remotePath) } returns null + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, "/a/b/1.JPG") } returns null + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, remotePath) + + assertNull(result) + } + + @Test + fun getUploadByPathsReturnsNullWhenRemotePathHasNoExtension() { + val remotePath = "/a/b/fileWithoutExtension" + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, remotePath) } returns null + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, remotePath) + + assertNull(result) + } + + @Test + fun getUploadByPathsReturnsNullWhenRemotePathIsEmpty() { + val remotePath = "" + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, remotePath) } returns null + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, remotePath) + + assertNull(result) + } + + @Test + fun getUploadByPathsHandlesDeepNestedPathWithUppercaseExtension() { + toggleWCFRestrictions(true) + + val searchPath = "/a/b/c/d/e/file.PNG" + val storedPath = "/a/b/c/d/e/file.png" + val entity = buildEntity(remotePath = storedPath) + + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } returns null + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } returns entity + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, searchPath) + + assertNotNull(result) + assertEquals(storedPath, result?.remotePath) + } + + @Test + fun getUploadByPathsOnlyTogglesExtensionNotRestOfFilenameOrPath() { + toggleWCFRestrictions(true) + + val searchPath = "/a/b/AAA/b/1.txt" + val wrongPath = "/a/b/aaa/b/1.TXT" + val storedPath = "/a/b/AAA/b/1.TXT" + val entity = buildEntity(remotePath = storedPath) + + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } returns null + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } returns entity + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, wrongPath) } returns null + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, searchPath) + + assertNotNull(result) + assertEquals(storedPath, result?.remotePath) + verify(exactly = 0) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, wrongPath) } + } + + @Test + fun getUploadByPathsCaseInsensitiveExtensionFallbackWCFDisabled() { + toggleWCFRestrictions(false) + + val searchPath = "/a/b/AAA/b/1.txt" + val storedPath = "/a/b/AAA/b/1.TXT" + + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } returns null + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, searchPath) + + assertNull(result) + + // counts getUploadByAccountAndPaths call times based on fileUploadHelper.getUploadByPaths call + verify(exactly = 1) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } + verify(exactly = 0) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } + } + + @Test + fun getUploadByPathsFindsRecordWhenDBHasLowercaseExtensionButSearchUsesUppercaseWCFDisabled() { + toggleWCFRestrictions(false) + + val searchPath = "/a/b/AAA/b/1.TXT" + val storedPath = "/a/b/AAA/b/1.txt" + + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } returns null + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, searchPath) + + assertNull(result) + verify(exactly = 1) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } + verify(exactly = 0) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } + } + + @Test + fun getUploadByPathsHandlesDeepNestedPathWithUppercaseExtensionWCFDisabled() { + toggleWCFRestrictions(false) + + val searchPath = "/a/b/c/d/e/file.PNG" + val storedPath = "/a/b/c/d/e/file.png" + + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } returns null + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, searchPath) + + assertNull(result) + verify(exactly = 1) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } + verify(exactly = 0) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } + } + + @Test + fun getUploadByPathsOnlyTogglesExtensionNotRestOfFilenameOrPathWCFDisabled() { + toggleWCFRestrictions(false) + + val searchPath = "/a/b/AAA/b/1.txt" + val wrongPath = "/a/b/aaa/b/1.TXT" + val storedPath = "/a/b/AAA/b/1.TXT" + + every { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } returns null + + val result = fileUploadHelper.getUploadByPaths(accountName, localPath, searchPath) + + assertNull(result) + verify(exactly = 1) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, searchPath) } + verify(exactly = 0) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, storedPath) } + verify(exactly = 0) { uploadDao.getUploadByAccountAndPaths(accountName, localPath, wrongPath) } + } + + @Test + fun toOCUploadMapsAllFieldsCorrectlyWithoutCapability() { + val entity = buildEntity( + id = 42, + localPath = localPath, + remotePath = "/remote/photo.jpg", + accountName = accountName, + fileSize = 2048L, + status = UploadStatus.UPLOAD_IN_PROGRESS.value, + lastResult = UploadResult.UPLOADED.value, + nameCollisionPolicy = NameCollisionPolicy.RENAME.serialize(), + isWifiOnly = 1, + isWhileChargingOnly = 1, + isCreateRemoteFolder = 1, + createdBy = 2, + folderUnlockToken = "token-abc", + uploadEndTimestampLong = 1234567890L + ) + + val result = entity.toOCUpload() + + assertNotNull(result) + assertEquals(42L, result!!.uploadId) + assertEquals(localPath, result.localPath) + assertEquals("/remote/photo.jpg", result.remotePath) + assertEquals(accountName, result.accountName) + assertEquals(2048L, result.fileSize) + assertEquals(UploadStatus.UPLOAD_IN_PROGRESS, result.uploadStatus) + assertEquals(UploadResult.UPLOADED, result.lastResult) + assertEquals(NameCollisionPolicy.RENAME, result.nameCollisionPolicy) + assertEquals(true, result.isUseWifiOnly) + assertEquals(true, result.isWhileChargingOnly) + assertEquals(true, result.isCreateRemoteFolder) + assertEquals(2, result.createdBy) + assertEquals("token-abc", result.folderUnlockToken) + assertEquals(1234567890L, result.uploadEndTimestamp) + } + + @Test + fun toOCUploadMapsBooleanFalseFieldsCorrectly() { + val entity = buildEntity(isWifiOnly = 0, isWhileChargingOnly = 0, isCreateRemoteFolder = 0) + + val result = entity.toOCUpload() + + assertNotNull(result) + assertEquals(false, result!!.isUseWifiOnly) + assertEquals(false, result.isWhileChargingOnly) + assertEquals(false, result.isCreateRemoteFolder) + } + + @Test + fun toOCUploadReturnsNullWhenLocalPathIsNull() { + val entity = buildEntity().copy(localPath = null) + + val result = entity.toOCUpload() + + // OCUpload constructor throws IllegalArgumentException for null localPath + assertNull(result) + } + + @Test + fun toOCUploadReturnsNullWhenRemotePathIsNull() { + val entity = buildEntity().copy(remotePath = null) + + val result = entity.toOCUpload() + + // OCUpload constructor throws IllegalArgumentException for null remotePath + assertNull(result) + } + + @Test + fun toOCUploadAppliesAutoRenameWhenCapabilityIsProvided() { + val capability = mockk(relaxed = true) + val entity = buildEntity(remotePath = "/remote/photo.jpg") + val result = entity.toOCUpload(capability) + assertNotNull(result) + } + + @Test + fun toOCUploadHandlesNullOptionalFieldsGracefully() { + val entity = UploadEntity( + id = null, + localPath = localPath, + remotePath = "/remote/photo.jpg", + accountName = accountName, + fileSize = null, + status = null, + localBehaviour = null, + uploadTime = null, + nameCollisionPolicy = null, + isCreateRemoteFolder = null, + uploadEndTimestamp = null, + uploadEndTimestampLong = null, + lastResult = null, + isWhileChargingOnly = null, + isWifiOnly = null, + createdBy = null, + folderUnlockToken = null + ) + + // should not throw; optional fields simply stay at OCUpload defaults + val result = entity.toOCUpload() + + assertNotNull(result) + } + + @Test + fun toUploadEntityMapsAllFieldsCorrectlyWhenUploadIdIsSet() { + val upload = buildOCUpload().apply { + uploadId = 10L + fileSize = 512L + uploadStatus = UploadStatus.UPLOAD_FAILED + nameCollisionPolicy = NameCollisionPolicy.ASK_USER + isCreateRemoteFolder = false + localAction = 2 + isUseWifiOnly = true + isWhileChargingOnly = true + lastResult = UploadResult.FILE_NOT_FOUND + createdBy = 3 + folderUnlockToken = "unlock-token" + uploadEndTimestamp = 9876543210L + } + + val entity = upload.toUploadEntity() + + assertEquals(10, entity.id) + assertEquals(localPath, entity.localPath) + assertEquals("/remote/path/photo.jpg", entity.remotePath) + assertEquals(accountName, entity.accountName) + assertEquals(512L, entity.fileSize) + assertEquals(UploadStatus.UPLOAD_FAILED.value, entity.status) + assertEquals(NameCollisionPolicy.ASK_USER.serialize(), entity.nameCollisionPolicy) + assertEquals(0, entity.isCreateRemoteFolder) + assertEquals(2, entity.localBehaviour) + assertEquals(1, entity.isWifiOnly) + assertEquals(1, entity.isWhileChargingOnly) + assertEquals(UploadResult.FILE_NOT_FOUND.value, entity.lastResult) + assertEquals(3, entity.createdBy) + assertEquals("unlock-token", entity.folderUnlockToken) + assertEquals(9876543210L, entity.uploadEndTimestampLong) + assertEquals(0, entity.uploadEndTimestamp) // legacy field always 0 + assertNull(entity.uploadTime) // always null + } + + @Test + fun toUploadEntitySetsIdToNullWhenUploadIdIsNotValidToAllowRoomAutoGenerate() { + val upload = buildOCUpload().apply { uploadId = -1L } + + val entity = upload.toUploadEntity() + + assertNull(entity.id) + } + + @Test + fun toUploadEntityPreservesExistingIdWhenUploadIdIsPositive() { + val upload = buildOCUpload().apply { uploadId = 99L } + + val entity = upload.toUploadEntity() + + assertEquals(99, entity.id) + } + + @Test + fun toUploadEntityIsUseWifiOnlyIsFalseShouldReturnZero() { + val upload = buildOCUpload().apply { isUseWifiOnly = false } + + val entity = upload.toUploadEntity() + + assertEquals(0, entity.isWifiOnly) + } + + @Test + fun toUploadEntityMapsIsWhileChargingIsFalseShouldReturnZero() { + val upload = buildOCUpload().apply { isWhileChargingOnly = false } + + val entity = upload.toUploadEntity() + + assertEquals(0, entity.isWhileChargingOnly) + } + + @Test + fun toUploadEntityMapsIsCreateRemoteFolderTrueShouldReturnOne() { + val upload = buildOCUpload().apply { isCreateRemoteFolder = true } + + val entity = upload.toUploadEntity() + + assertEquals(1, entity.isCreateRemoteFolder) + } + + @Test + fun testEntityAndOCUploadConversionTogether() { + val original = buildOCUpload().apply { + uploadId = 7L + fileSize = 333L + uploadStatus = UploadStatus.UPLOAD_FAILED + nameCollisionPolicy = NameCollisionPolicy.RENAME + isCreateRemoteFolder = true + localAction = 1 + isUseWifiOnly = true + isWhileChargingOnly = false + lastResult = UploadResult.NETWORK_CONNECTION + createdBy = 1 + folderUnlockToken = "rt-token" + uploadEndTimestamp = 111L + } + + val entity = original.toUploadEntity() + val restored = entity.toOCUpload() + + assertNotNull(restored) + assertEquals(original.uploadId, restored!!.uploadId) + assertEquals(original.localPath, restored.localPath) + assertEquals(original.remotePath, restored.remotePath) + assertEquals(original.accountName, restored.accountName) + assertEquals(original.fileSize, restored.fileSize) + assertEquals(original.uploadStatus, restored.uploadStatus) + assertEquals(original.nameCollisionPolicy, restored.nameCollisionPolicy) + assertEquals(original.isCreateRemoteFolder, restored.isCreateRemoteFolder) + assertEquals(original.localAction, restored.localAction) + assertEquals(original.isUseWifiOnly, restored.isUseWifiOnly) + assertEquals(original.isWhileChargingOnly, restored.isWhileChargingOnly) + assertEquals(original.lastResult, restored.lastResult) + assertEquals(original.createdBy, restored.createdBy) + assertEquals(original.folderUnlockToken, restored.folderUnlockToken) + assertEquals(original.uploadEndTimestamp, restored.uploadEndTimestamp) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt new file mode 100644 index 000000000000..e59f6e10fe7b --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt @@ -0,0 +1,100 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils + +import com.owncloud.android.AbstractIT +import androidx.test.platform.app.InstrumentationRegistry +import com.owncloud.android.utils.FileUtil.isFolderWritable +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +class FileUtilTest : AbstractIT() { + + private lateinit var context: android.content.Context + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + @Test + fun testIsFolderWritableWhenGivenCacheDirShouldReturnTrue() = runBlocking { + val writableDir = context.cacheDir + val result = isFolderWritable(writableDir) + assertTrue("Internal cache directory should be writable", result) + } + + @Test + fun testIsFolderWritableWhenGivenNonExistentDirShouldReturnFalse() = runBlocking { + val nonExistentFile = File(context.cacheDir, "ghost_folder_123") + val result = isFolderWritable(nonExistentFile) + assertFalse("Non-existent folder should not be writable", result) + } + + @Test + fun testIsFolderWritableWhenGivenFileShouldReturnFalse() = runBlocking { + val regularFile = File(context.cacheDir, "test_file.txt") + regularFile.createNewFile() + val result = isFolderWritable(regularFile) + assertFalse("A regular file should not be treated as a writable folder", result) + } + + @Test + fun testIsFolderWritableWhenGivenNullShouldReturnFalse() = runBlocking { + val result = isFolderWritable(null) + assertFalse("Null input should return false", result) + } + + @Test + fun testIsFolderWritableWhenGivenReadOnlyDirShouldReturnFalse() = runBlocking { + val readOnlyDir = File(context.cacheDir, "readonly_test") + readOnlyDir.mkdir() + + try { + readOnlyDir.setReadOnly() + val result = isFolderWritable(readOnlyDir) + assertFalse("Read-only directory should return false", result) + } finally { + readOnlyDir.setWritable(true) + readOnlyDir.delete() + } + } + + @Test + fun testIsFolderWritableWhenGivenNestedStructureShouldReturnTrue() = runBlocking { + val rootDir = File(context.cacheDir, "test_root") + rootDir.mkdir() + + try { + val result = isFolderWritable(rootDir) + assertTrue("Should be able to create and delete nested temp structures", result) + val children = rootDir.list() + assertTrue("Temp directory should have been cleaned up", children == null || children.isEmpty()) + } finally { + rootDir.delete() + } + } + + @Test + fun testIsFolderWritableWhenGivenReadonlyNestedStructureShouldReturnFalse() = runBlocking { + val readOnlyDir = File(context.cacheDir, "locked_dir") + readOnlyDir.mkdir() + readOnlyDir.setReadOnly() + + try { + val result = isFolderWritable(readOnlyDir) + assertFalse("Should return false if temp folder creation fails", result) + } finally { + readOnlyDir.setWritable(true) + readOnlyDir.delete() + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/SessionMixinTest.kt b/app/src/androidTest/java/com/owncloud/android/utils/SessionMixinTest.kt new file mode 100644 index 000000000000..11914ed394a4 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/SessionMixinTest.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils + +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.client.mixins.SessionMixin +import com.owncloud.android.AbstractIT +import com.owncloud.android.ui.activity.FileDisplayActivity +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class SessionMixinTest : AbstractIT() { + + private lateinit var userAccountManager: UserAccountManager + private lateinit var session: SessionMixin + + private var scenario: ActivityScenario? = null + val intent = Intent(ApplicationProvider.getApplicationContext(), FileDisplayActivity::class.java) + + @get:Rule + val activityRule = ActivityScenarioRule(intent) + + @Before + fun setUp() { + userAccountManager = UserAccountManagerImpl.fromContext(targetContext) + + scenario = activityRule.scenario + scenario?.onActivity { sut -> + session = SessionMixin( + sut, + userAccountManager + ) + } + } + + @Test + fun startAccountCreation() { + session.startAccountCreation() + + scenario = activityRule.scenario + scenario?.onActivity { sut -> + assert(sut.account.name == userAccountManager.accounts.first().name) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/SnackbarTests.kt b/app/src/androidTest/java/com/owncloud/android/utils/SnackbarTests.kt new file mode 100644 index 000000000000..90980f2e5350 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/SnackbarTests.kt @@ -0,0 +1,142 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.utils + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.annotation.StringRes +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intending +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.nextcloud.client.onboarding.FirstRunActivity +import com.nextcloud.test.TestActivity +import com.owncloud.android.R +import com.owncloud.android.authentication.AuthenticatorActivity +import org.hamcrest.Matchers.anyOf +import org.junit.After +import org.junit.Before +import org.junit.Test + +class SnackbarTests { + + class NormalTestFragment : Fragment() + class DialogTestFragment : DialogFragment() + class BottomSheetTestFragment : BottomSheetDialogFragment() + + @Before + fun setUp() { + Intents.init() + val cancelledResult = Instrumentation.ActivityResult(Activity.RESULT_CANCELED, Intent()) + intending( + anyOf( + hasComponent(AuthenticatorActivity::class.java.name), + hasComponent(FirstRunActivity::class.java.name) + ) + ).respondWith(cancelledResult) + } + + @After + fun tearDown() { + Intents.release() + } + + private fun assertSnackbarVisible(msg: String) { + onView(withText(msg)).check(matches(isDisplayed())) + } + + private fun assertSnackbarVisible(@StringRes msgRes: Int) { + onView(withText(msgRes)).check(matches(isDisplayed())) + } + + private fun testFragmentSnackbar(fragment: Fragment, @StringRes msgRes: Int) { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + sut.addFragment(fragment) + } + scenario.onActivity { + DisplayUtils.showSnackMessage(fragment, msgRes) + } + assertSnackbarVisible(msgRes) + } + } + + @Test + fun testNormalFragmentSnackbar() { + testFragmentSnackbar(NormalTestFragment(), R.string.app_name) + } + + @Test + fun testDialogFragmentSnackbar() { + testFragmentSnackbar(DialogTestFragment(), R.string.app_name) + } + + @Test + fun testBottomSheetFragmentSnackbar() { + testFragmentSnackbar(BottomSheetTestFragment(), R.string.app_name) + } + + @Test + fun testNullFragmentSnackbarShouldNotCrash() { + DisplayUtils.showSnackMessage(null as Fragment?, R.string.app_name) + } + + @Test + fun testActivityStringResSnackbar() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + DisplayUtils.showSnackMessage(sut, R.string.app_name) + } + assertSnackbarVisible(R.string.app_name) + } + } + + @Test + fun testActivityStringSnackbar() { + launchActivity().use { scenario -> + var message = "" + scenario.onActivity { sut -> + message = sut.getString(R.string.app_name) + DisplayUtils.showSnackMessage(sut, message) + } + assertSnackbarVisible(message) + } + } + + @Test + fun testViewStringResSnackbar() { + launchActivity().use { scenario -> + scenario.onActivity { sut -> + val contentView = sut.findViewById(android.R.id.content) + DisplayUtils.showSnackMessage(contentView, R.string.app_name) + } + assertSnackbarVisible(R.string.app_name) + } + } + + @Test + fun testViewStringSnackbar() { + launchActivity().use { scenario -> + var message = "" + scenario.onActivity { sut -> + message = sut.getString(R.string.app_name) + val contentView = sut.findViewById(android.R.id.content) + DisplayUtils.showSnackMessage(contentView, message) + } + assertSnackbarVisible(message) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/SyncedFolderUtilsTest.kt b/app/src/androidTest/java/com/owncloud/android/utils/SyncedFolderUtilsTest.kt new file mode 100644 index 000000000000..310affdba943 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/SyncedFolderUtilsTest.kt @@ -0,0 +1,272 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import com.nextcloud.client.preferences.SubFolderRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.MediaFolder +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.utils.SyncedFolderUtils.hasExcludePrefix +import org.apache.commons.io.FileUtils +import org.junit.AfterClass +import org.junit.Assert +import org.junit.BeforeClass +import org.junit.Test +import java.io.File +import java.util.Arrays + +class SyncedFolderUtilsTest : AbstractIT() { + @Test + fun assertCoverFilenameUnqualified() { + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload(COVER)) + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("cover.JPG")) + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("cover.jpeg")) + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("cover.JPEG")) + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("COVER.jpg")) + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload(FOLDER)) + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("Folder.jpeg")) + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("FOLDER.jpg")) + Assert.assertFalse(SyncedFolderUtils.isFileNameQualifiedForAutoUpload(THUMBDATA_FILE)) + } + + @Test + fun assertImageFilenameQualified() { + Assert.assertTrue(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("image.jpg")) + Assert.assertTrue(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("screenshot.JPG")) + Assert.assertTrue(SyncedFolderUtils.isFileNameQualifiedForAutoUpload(IMAGE_JPEG)) + Assert.assertTrue(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("image.JPEG")) + Assert.assertTrue(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("SCREENSHOT.jpg")) + Assert.assertTrue(SyncedFolderUtils.isFileNameQualifiedForAutoUpload(SELFIE)) + Assert.assertTrue(SyncedFolderUtils.isFileNameQualifiedForAutoUpload("screenshot.PNG")) + } + + @Test + fun assertMediaFolderNullSafe() { + val folder: MediaFolder? = null + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertMediaFolderCustomQualified() { + val folder = MediaFolder() + folder.type = MediaFolderType.CUSTOM + Assert.assertTrue(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertMediaFolderVideoUnqualified() { + val folder = MediaFolder().apply { + absolutePath = getDummyFile(THUMBDATA_FOLDER).absolutePath + type = MediaFolderType.VIDEO + numberOfFiles = 0L + } + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertMediaFolderVideoQualified() { + val folder = MediaFolder().apply { + absolutePath = getDummyFile(THUMBDATA_FOLDER).absolutePath + type = MediaFolderType.VIDEO + numberOfFiles = 20L + } + Assert.assertTrue(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertMediaFolderImagesQualified() { + val folder = MediaFolder().apply { + absolutePath = getDummyFile(THUMBDATA_FOLDER).absolutePath + type = MediaFolderType.IMAGE + numberOfFiles = 4L + filePaths = Arrays.asList( + getDummyFile(SELFIE).absolutePath, + getDummyFile(SCREENSHOT).absolutePath, + getDummyFile(IMAGE_JPEG).absolutePath, + getDummyFile(IMAGE_BITMAP).absolutePath + ) + } + Assert.assertTrue(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertMediaFolderImagesEmptyUnqualified() { + val folder = MediaFolder().apply { + absolutePath = getDummyFile(THUMBDATA_FOLDER).absolutePath + type = MediaFolderType.IMAGE + numberOfFiles = 0L + } + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertMediaFolderImagesNoImagesUnqualified() { + val folder = MediaFolder().apply { + absolutePath = getDummyFile(THUMBDATA_FOLDER).absolutePath + type = MediaFolderType.IMAGE + numberOfFiles = 3L + filePaths = Arrays.asList( + getDummyFile(SONG_ZERO).absolutePath, + getDummyFile(SONG_ONE).absolutePath, + getDummyFile(SONG_TWO).absolutePath + ) + } + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertMediaFolderImagesMusicAlbumWithCoverArtUnqualified() { + val folder = MediaFolder().apply { + absolutePath = getDummyFile(THUMBDATA_FOLDER).absolutePath + type = MediaFolderType.IMAGE + numberOfFiles = 3L + filePaths = Arrays.asList( + getDummyFile(COVER).absolutePath, + getDummyFile(SONG_ONE).absolutePath, + getDummyFile(SONG_TWO).absolutePath + ) + } + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertMediaFolderImagesMusicAlbumWithFolderArtUnqualified() { + val folder = MediaFolder().apply { + absolutePath = getDummyFile(THUMBDATA_FOLDER).absolutePath + type = MediaFolderType.IMAGE + numberOfFiles = 3L + filePaths = Arrays.asList( + getDummyFile(FOLDER).absolutePath, + getDummyFile(SONG_ONE).absolutePath, + getDummyFile(SONG_TWO).absolutePath + ) + } + + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertSyncedFolderNullSafe() { + val folder: SyncedFolder? = null + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertUnqualifiedContentSyncedFolder() { + val localFolder = getDummyFile(THUMBDATA_FOLDER + File.separatorChar) + getDummyFile(THUMBDATA_FOLDER + File.separatorChar + THUMBDATA_FILE) + val folder = SyncedFolder( + localFolder.absolutePath, + "", + true, + false, + false, + true, + account.name, + 1, + 1, + true, + 0L, + MediaFolderType.IMAGE, + false, + SubFolderRule.YEAR_MONTH, + false, + SyncedFolder.NOT_SCANNED_YET + ) + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun assertUnqualifiedSyncedFolder() { + getDummyFile(THUMBNAILS_FOLDER + File.separatorChar + IMAGE_JPEG) + getDummyFile(THUMBNAILS_FOLDER + File.separatorChar + IMAGE_BITMAP) + val folder = SyncedFolder( + FileStorageUtils.getTemporalPath(account.name) + File.separatorChar + THUMBNAILS_FOLDER, + "", + true, + false, + false, + true, + account.name, + 1, + 1, + true, + 0L, + MediaFolderType.IMAGE, + false, + SubFolderRule.YEAR_MONTH, + false, + SyncedFolder.NOT_SCANNED_YET + ) + Assert.assertFalse(SyncedFolderUtils.isQualifyingMediaFolder(folder)) + } + + @Test + fun testInstantUploadPathIgnoreExcludedPrefixes() { + val testFiles = listOf( + "IMG_nnn.jpg", + "my_documents", + "Music", + ".trashed_IMG_nnn.jpg", + ".pending_IMG_nnn.jpg", + ".nomedia", + ".thumbdata_IMG_nnn", + ".thumbnail" + ).filter { !hasExcludePrefix(it) } + Assert.assertTrue(testFiles.size == 3) + } + + companion object { + private const val SELFIE = "selfie.png" + private const val SCREENSHOT = "screenshot.JPG" + private const val IMAGE_JPEG = "image.jpeg" + private const val IMAGE_BITMAP = "image.bmp" + private const val SONG_ZERO = "song0.mp3" + private const val SONG_ONE = "song1.mp3" + private const val SONG_TWO = "song2.mp3" + private const val FOLDER = "folder.JPG" + private const val COVER = "cover.jpg" + private const val THUMBNAILS_FOLDER = ".thumbnails/" + private const val THUMBDATA_FOLDER = "valid_folder/" + private const val THUMBDATA_FILE = ".thumbdata4--1967290299" + private const val ITERATION = 100 + + @BeforeClass + @JvmStatic + fun setUp() { + val tempPath = + File( + FileStorageUtils.getTemporalPath(account.name) + File.separatorChar + + THUMBNAILS_FOLDER + ) + if (!tempPath.exists()) { + tempPath.mkdirs() + } + + createFile(SELFIE, ITERATION) + createFile(SCREENSHOT, ITERATION) + createFile(IMAGE_JPEG, ITERATION) + createFile(IMAGE_BITMAP, ITERATION) + createFile(SONG_ZERO, ITERATION) + createFile(SONG_ONE, ITERATION) + createFile(SONG_TWO, ITERATION) + createFile(FOLDER, ITERATION) + createFile(COVER, ITERATION) + + createFile(THUMBDATA_FOLDER + File.separatorChar + THUMBDATA_FILE, ITERATION) + createFile(THUMBNAILS_FOLDER + File.separatorChar + IMAGE_JPEG, ITERATION) + createFile(THUMBNAILS_FOLDER + File.separatorChar + IMAGE_BITMAP, ITERATION) + } + + @AfterClass + @JvmStatic + fun tearDown() { + FileUtils.deleteDirectory(File(FileStorageUtils.getTemporalPath(account.name))) + } + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/utils/theme/CapabilityUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/theme/CapabilityUtilsIT.kt new file mode 100644 index 000000000000..c27e10425953 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/utils/theme/CapabilityUtilsIT.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils.theme + +import com.owncloud.android.AbstractIT +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OwnCloudVersion +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class CapabilityUtilsIT : AbstractIT() { + @Test + fun checkOutdatedWarning() { + assertFalse(test(NextcloudVersion.nextcloud_31)) + assertFalse(test(NextcloudVersion.nextcloud_30)) + + assertTrue(test(NextcloudVersion.nextcloud_29)) + assertTrue(test(NextcloudVersion.nextcloud_28)) + assertTrue(test(NextcloudVersion.nextcloud_27)) + assertTrue(test(NextcloudVersion.nextcloud_26)) + assertTrue(test(NextcloudVersion.nextcloud_25)) + assertTrue(test(NextcloudVersion.nextcloud_24)) + assertTrue(test(NextcloudVersion.nextcloud_23)) + assertTrue(test(NextcloudVersion.nextcloud_22)) + assertTrue(test(NextcloudVersion.nextcloud_21)) + assertTrue(test(OwnCloudVersion.nextcloud_20)) + } + + @Test + fun checkOutdatedWarningWithSubscription() { + assertFalse(test(NextcloudVersion.nextcloud_31)) + assertFalse(test(NextcloudVersion.nextcloud_30)) + + assertFalse(test(OwnCloudVersion.nextcloud_20, true)) + } + + private fun test(version: OwnCloudVersion, hasValidSubscription: Boolean = false): Boolean = + CapabilityUtils.checkOutdatedWarning(targetContext.resources, version, false, hasValidSubscription) +} diff --git a/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt b/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt new file mode 100644 index 000000000000..ed7d07d4db34 --- /dev/null +++ b/app/src/androidTestGeneric/java/com/nextcloud/client/di/VariantModuleTest.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.di + +import com.nextcloud.client.documentscan.AppScanOptionalFeature +import dagger.Component +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit test for [VariantModule] that tests the reflection-based approach + * to conditionally load the ScanPageContract. + */ +class VariantModuleTest { + + private lateinit var component: TestVariantComponent + + @Before + fun setup() { + component = DaggerVariantModuleTest_TestVariantComponent.create() + } + + @Test + fun testAppScanWhenNotAvailableShouldReturnError() { + val feature = component.appScanOptionalFeature() + + assertFalse(feature.isAvailable) + assertEquals(AppScanOptionalFeature.Stub, feature) + + try { + feature.getScanContract() + throw AssertionError("Expected UnsupportedOperationException") + } catch (e: UnsupportedOperationException) { + assertTrue(e.message?.contains("not available") == true) + } + } + + @Component(modules = [VariantModule::class]) + interface TestVariantComponent { + fun appScanOptionalFeature(): AppScanOptionalFeature + } +} diff --git a/app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt b/app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt new file mode 100644 index 000000000000..3ac80ebe9d6c --- /dev/null +++ b/app/src/androidTestGplay/java/com/nextcloud/client/di/VariantModuleTest.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.di + +import com.nextcloud.client.documentscan.AppScanOptionalFeature +import dagger.Component +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit test for [VariantModule] that tests the reflection-based approach + * to conditionally load the ScanPageContract. + */ +class VariantModuleTest { + + private lateinit var component: TestVariantComponent + + @Before + fun setup() { + component = DaggerVariantModuleTest_TestVariantComponent.create() + } + + @Test + fun testAppScanWhenAvailableShouldReturnContract() { + val feature = component.appScanOptionalFeature() + + assertTrue(feature.isAvailable) + assertNotEquals(AppScanOptionalFeature.Stub, feature) + + assertNotNull(feature.getScanContract()) + } + + @Component(modules = [VariantModule::class]) + interface TestVariantComponent { + fun appScanOptionalFeature(): AppScanOptionalFeature + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..4627fc2a6e93 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/app/src/debug/java/com/nextcloud/client/di/BuildTypeComponentsModule.kt b/app/src/debug/java/com/nextcloud/client/di/BuildTypeComponentsModule.kt new file mode 100644 index 000000000000..b3022c74aef3 --- /dev/null +++ b/app/src/debug/java/com/nextcloud/client/di/BuildTypeComponentsModule.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import com.nextcloud.test.InjectionTestActivity +import com.nextcloud.test.TestActivity +import dagger.Module +import dagger.android.ContributesAndroidInjector + +/** + * Register classes that require dependency injection. This class is used by Dagger compiler only. + */ +@Module +interface BuildTypeComponentsModule { + @ContributesAndroidInjector + fun testActivity(): TestActivity? + + @ContributesAndroidInjector + fun injectionTestActivity(): InjectionTestActivity? +} diff --git a/app/src/debug/java/com/nextcloud/test/InjectionTestActivity.kt b/app/src/debug/java/com/nextcloud/test/InjectionTestActivity.kt new file mode 100644 index 000000000000..2e62f362777c --- /dev/null +++ b/app/src/debug/java/com/nextcloud/test/InjectionTestActivity.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.databinding.ActivityInjectionTestBinding +import javax.inject.Inject + +/** + * Sample activity to check test overriding injections + */ +class InjectionTestActivity : + AppCompatActivity(), + Injectable { + @Inject + lateinit var appPreferences: AppPreferences + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityInjectionTestBinding.inflate(layoutInflater) + // random pref, just needs to match the one in the test + binding.text.text = appPreferences.lastUploadPath + setContentView(binding.root) + } +} diff --git a/app/src/debug/java/com/nextcloud/test/TestActivity.kt b/app/src/debug/java/com/nextcloud/test/TestActivity.kt new file mode 100644 index 000000000000..5bbbf0258550 --- /dev/null +++ b/app/src/debug/java/com/nextcloud/test/TestActivity.kt @@ -0,0 +1,156 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.test + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.nextcloud.client.database.NextcloudDatabase +import com.nextcloud.client.jobs.download.FileDownloadWorker +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.network.Connectivity +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.utils.EditorUtils +import com.owncloud.android.R +import com.owncloud.android.databinding.TestLayoutBinding +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.lib.resources.status.OwnCloudVersion +import com.owncloud.android.services.OperationsService +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.OnEnforceableRefreshListener +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.helpers.FileOperationsHelper + +class TestActivity : + FileActivity(), + FileFragment.ContainerActivity, + SwipeRefreshLayout.OnRefreshListener, + OnEnforceableRefreshListener { + lateinit var fragment: Fragment + lateinit var secondaryFragment: Fragment + + private lateinit var storage: FileDataStorageManager + private lateinit var fileOperation: FileOperationsHelper + private lateinit var binding: TestLayoutBinding + + val connectivityServiceMock: ConnectivityService = object : ConnectivityService { + override fun isNetworkAndServerAvailable(callback: ConnectivityService.GenericCallback) = Unit + + override fun isConnected(): Boolean = false + + override fun isInternetWalled(): Boolean = false + + override fun getConnectivity(): Connectivity = Connectivity.CONNECTED_WIFI + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = TestLayoutBinding.inflate(layoutInflater) + setContentView(binding.root) + } + + fun addFragment(fragment: Fragment) { + this.fragment = fragment + val transaction = supportFragmentManager.beginTransaction() + transaction.replace(R.id.main_fragment, fragment) + transaction.commit() + } + + /** + * Adds a secondary fragment to the activity with the given tag. + * + * If you have to use this, your Fragments are coupled, and you should feel bad. + */ + fun addSecondaryFragment(fragment: Fragment, tag: String) { + this.secondaryFragment = fragment + val transaction = supportFragmentManager.beginTransaction() + transaction.replace(R.id.secondary_fragment, fragment, tag) + transaction.commit() + } + + /** + * Adds a View to the activity. + * + * If you have to use this, your Fragment is coupled to your Activity and you should feel bad. + */ + fun addView(view: View) { + handler.post { + binding.rootLayout.addView(view) + } + } + + override fun onBrowsedDownTo(folder: OCFile?) { + TODO("Not yet implemented") + } + + override fun getOperationsServiceBinder(): OperationsService.OperationsServiceBinder? = null + + override fun showSortListGroup(show: Boolean) { + // not needed + } + + override fun showDetails(file: OCFile?) { + TODO("Not yet implemented") + } + + override fun showDetails(file: OCFile?, activeTab: Int) { + TODO("Not yet implemented") + } + + override fun getFileUploaderHelper(): FileUploadHelper = FileUploadHelper.instance() + + override fun getFileDownloadProgressListener(): FileDownloadWorker.FileDownloadProgressListener? = null + + override fun getStorageManager(): FileDataStorageManager { + if (!this::storage.isInitialized) { + storage = FileDataStorageManager(user.get(), contentResolver) + + if (!storage.capabilityExistsForAccount(account.name)) { + val ocCapability = OCCapability() + ocCapability.versionMayor = OwnCloudVersion.nextcloud_20.majorVersionNumber + storage.saveCapabilities(ocCapability) + } + } + + return storage + } + + override fun getFileOperationsHelper(): FileOperationsHelper { + if (!this::fileOperation.isInitialized) { + fileOperation = FileOperationsHelper( + this, + userAccountManager, + connectivityServiceMock, + EditorUtils( + ArbitraryDataProviderImpl( + NextcloudDatabase.getInstance(baseContext).arbitraryDataDao() + ) + ) + ) + } + + return fileOperation + } + + override fun onTransferStateChanged(file: OCFile?, downloading: Boolean, uploading: Boolean) { + TODO("Not yet implemented") + } + + override fun onRefresh(enforced: Boolean) { + TODO("Not yet implemented") + } + + override fun onRefresh() { + TODO("Not yet implemented") + } +} diff --git a/app/src/debug/res/layout/activity_injection_test.xml b/app/src/debug/res/layout/activity_injection_test.xml new file mode 100644 index 000000000000..2f83d137317d --- /dev/null +++ b/app/src/debug/res/layout/activity_injection_test.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/app/src/debug/res/layout/avatar_fragment.xml b/app/src/debug/res/layout/avatar_fragment.xml new file mode 100644 index 000000000000..6e656c8f57f2 --- /dev/null +++ b/app/src/debug/res/layout/avatar_fragment.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/src/generic/google-services.json b/app/src/generic/google-services.json similarity index 100% rename from src/generic/google-services.json rename to app/src/generic/google-services.json diff --git a/app/src/generic/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt b/app/src/generic/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt new file mode 100644 index 000000000000..34224244fc09 --- /dev/null +++ b/app/src/generic/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.appReview + +import androidx.appcompat.app.AppCompatActivity +import com.nextcloud.appReview.InAppReviewHelper +import com.nextcloud.client.preferences.AppPreferences + +class InAppReviewHelperImpl(appPreferences: AppPreferences) : InAppReviewHelper { + override fun resetAndIncrementAppRestartCounter() { + } + + override fun showInAppReview(activity: AppCompatActivity) { + } +} diff --git a/app/src/generic/java/com/nextcloud/client/di/VariantComponentsModule.java b/app/src/generic/java/com/nextcloud/client/di/VariantComponentsModule.java new file mode 100644 index 000000000000..1b2a178bdfab --- /dev/null +++ b/app/src/generic/java/com/nextcloud/client/di/VariantComponentsModule.java @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di; + +import dagger.Module; + +@Module +abstract class VariantComponentsModule { +} diff --git a/app/src/generic/java/com/owncloud/android/utils/PushUtils.java b/app/src/generic/java/com/owncloud/android/utils/PushUtils.java new file mode 100644 index 000000000000..139377f210d9 --- /dev/null +++ b/app/src/generic/java/com/owncloud/android/utils/PushUtils.java @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils; + +import android.content.Context; + +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.owncloud.android.MainApp; +import com.owncloud.android.datamodel.SignatureVerification; + +import java.security.Key; + +public final class PushUtils { + public static final String KEY_PUSH = "push"; + + private PushUtils() { + } + + public static void pushRegistrationToServer(final UserAccountManager accountManager, final String pushToken) { + // do nothing + } + + public static void reinitKeys(UserAccountManager accountManager) { + Context context = MainApp.getAppContext(); + AppPreferencesImpl.fromContext(context).setKeysReInitEnabled(); + } + + public static Key readKeyFromFile(boolean readPublicKey) { + return null; + } + + public static SignatureVerification verifySignature( + final Context context, + final UserAccountManager accountManager, + final byte[] signatureBytes, + final byte[] subjectBytes + ) { + return null; + } + +} diff --git a/app/src/generic/java/com/owncloud/android/utils/SecurityUtils.java b/app/src/generic/java/com/owncloud/android/utils/SecurityUtils.java new file mode 100644 index 000000000000..97b19a2ce0dd --- /dev/null +++ b/app/src/generic/java/com/owncloud/android/utils/SecurityUtils.java @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils; + +public class SecurityUtils { +} diff --git a/app/src/gplay/AndroidManifest.xml b/app/src/gplay/AndroidManifest.xml new file mode 100644 index 000000000000..d566a24e8b90 --- /dev/null +++ b/app/src/gplay/AndroidManifest.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/gplay/google-services.json b/app/src/gplay/google-services.json similarity index 100% rename from src/gplay/google-services.json rename to app/src/gplay/google-services.json diff --git a/app/src/gplay/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt b/app/src/gplay/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt new file mode 100644 index 000000000000..01208c9a7a6c --- /dev/null +++ b/app/src/gplay/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt @@ -0,0 +1,119 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.appReview + +import androidx.appcompat.app.AppCompatActivity +import com.google.android.gms.tasks.Task +import com.google.android.play.core.review.ReviewInfo +import com.google.android.play.core.review.ReviewManager +import com.google.android.play.core.review.ReviewManagerFactory +import com.nextcloud.appReview.AppReviewShownModel +import com.nextcloud.appReview.InAppReviewHelper +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.extensions.getFormattedStringDate +import com.nextcloud.utils.extensions.isCurrentYear +import com.owncloud.android.lib.common.utils.Log_OC + +// Reference: https://developer.android.com/guide/playcore/in-app-review +/** + * This class responsible to handle & manage in-app review related methods + */ +class InAppReviewHelperImpl(val appPreferences: AppPreferences) : InAppReviewHelper { + + override fun resetAndIncrementAppRestartCounter() { + val appReviewShownModel = appPreferences.inAppReviewData + val currentTimeMills = System.currentTimeMillis() + + if (appReviewShownModel != null) { + if (currentTimeMills.isCurrentYear(appReviewShownModel.firstShowYear)) { + appReviewShownModel.appRestartCount += 1 + appPreferences.setInAppReviewData(appReviewShownModel) + } else { + resetReviewShownModel() + } + } else { + resetReviewShownModel() + } + } + + private fun resetReviewShownModel() { + val appReviewShownModel = AppReviewShownModel( + System.currentTimeMillis().getFormattedStringDate(YEAR_FORMAT), + 1, + 0, + null + ) + appPreferences.setInAppReviewData(appReviewShownModel) + } + + override fun showInAppReview(activity: AppCompatActivity) { + val appReviewShownModel = appPreferences.inAppReviewData + val currentTimeMills = System.currentTimeMillis() + + appReviewShownModel?.let { + if (it.appRestartCount >= MIN_APP_RESTARTS_REQ && + currentTimeMills.isCurrentYear(it.firstShowYear) && + it.reviewShownCount < MAX_DISPLAY_PER_YEAR + ) { + doAppReview(activity) + } else { + Log_OC.d( + TAG, + "Yearly limit has been reached or minimum app restarts are not completed: $appReviewShownModel" + ) + } + } + } + + private fun doAppReview(activity: AppCompatActivity) { + val manager = ReviewManagerFactory.create(activity) + val request: Task = manager.requestReviewFlow() + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + // We can get the ReviewInfo object + val reviewInfo: ReviewInfo = task.result!! + launchAppReviewFlow(manager, activity, reviewInfo) + } else { + // There was some problem, log or handle the error code. + Log_OC.e(TAG, "Failed to get ReviewInfo: ${task.exception?.message}") + } + } + } + + private fun launchAppReviewFlow(manager: ReviewManager, activity: AppCompatActivity, reviewInfo: ReviewInfo) { + val flow = manager.launchReviewFlow(activity, reviewInfo) + flow.addOnCompleteListener { _ -> + // The flow has finished. The API does not indicate whether the user + // reviewed or not, or even whether the review dialog was shown. Thus, no + // matter the result, we continue our app flow. + // Scenarios in which the flow won't shown: + // 1. Showing dialog to frequently + // 2. If quota is reached can be checked in official documentation + // 3. Flow won't be shown if user has already reviewed the app. User has to delete the review from play store to show the review dialog again + // Link for more info: https://stackoverflow.com/a/63342266 + Log_OC.d(TAG, "App Review flow is completed") + } + + // on successful showing review dialog increment the count and capture the date + val appReviewShownModel = appPreferences.inAppReviewData + appReviewShownModel?.let { + it.appRestartCount = 0 + it.reviewShownCount += 1 + it.lastReviewShownDate = System.currentTimeMillis().getFormattedStringDate(DATE_TIME_FORMAT) + appPreferences.setInAppReviewData(it) + } + } + + companion object { + private val TAG = InAppReviewHelperImpl::class.java.simpleName + const val YEAR_FORMAT = "yyyy" + const val DATE_TIME_FORMAT = "dd-MM-yyyy HH:mm:ss" + const val MIN_APP_RESTARTS_REQ = 10 // minimum app restarts required to ask the review + const val MAX_DISPLAY_PER_YEAR = 15 // maximum times to ask review in a year + } +} diff --git a/app/src/gplay/java/com/nextcloud/client/di/VariantComponentsModule.java b/app/src/gplay/java/com/nextcloud/client/di/VariantComponentsModule.java new file mode 100644 index 000000000000..6c807b6392b7 --- /dev/null +++ b/app/src/gplay/java/com/nextcloud/client/di/VariantComponentsModule.java @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di; + +import com.owncloud.android.authentication.ModifiedAuthenticatorActivity; +import com.owncloud.android.services.firebase.NCFirebaseMessagingService; + +import dagger.Module; +import dagger.android.ContributesAndroidInjector; + +@Module +abstract class VariantComponentsModule { + @ContributesAndroidInjector + abstract NCFirebaseMessagingService nCFirebaseMessagingService(); + + @ContributesAndroidInjector + abstract ModifiedAuthenticatorActivity modifiedAuthenticatorActivity(); +} diff --git a/app/src/gplay/java/com/owncloud/android/authentication/ModifiedAuthenticatorActivity.java b/app/src/gplay/java/com/owncloud/android/authentication/ModifiedAuthenticatorActivity.java new file mode 100644 index 000000000000..81e03ec834fd --- /dev/null +++ b/app/src/gplay/java/com/owncloud/android/authentication/ModifiedAuthenticatorActivity.java @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.authentication; + +import android.os.Bundle; + +import com.nextcloud.client.di.Injectable; +import com.owncloud.android.utils.GooglePlayUtils; + +public class ModifiedAuthenticatorActivity extends AuthenticatorActivity implements Injectable { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + GooglePlayUtils.checkPlayServices(this); + } + + @Override + protected void onResume() { + super.onResume(); + GooglePlayUtils.checkPlayServices(this); + } + +} diff --git a/app/src/gplay/java/com/owncloud/android/services/firebase/NCFirebaseMessagingService.java b/app/src/gplay/java/com/owncloud/android/services/firebase/NCFirebaseMessagingService.java new file mode 100644 index 000000000000..ce6beea9b7e9 --- /dev/null +++ b/app/src/gplay/java/com/owncloud/android/services/firebase/NCFirebaseMessagingService.java @@ -0,0 +1,103 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.services.firebase; + +import android.content.Intent; +import android.text.TextUtils; + +import com.google.firebase.messaging.Constants.MessageNotificationKeys; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.jobs.BackgroundJobManager; +import com.nextcloud.client.jobs.NotificationWork; +import com.nextcloud.client.preferences.AppPreferences; +import com.owncloud.android.R; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.PushUtils; + +import java.util.Map; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import dagger.android.AndroidInjection; + +public class NCFirebaseMessagingService extends FirebaseMessagingService { + @Inject AppPreferences preferences; + @Inject UserAccountManager accountManager; + @Inject BackgroundJobManager backgroundJobManager; + + static final String TAG = "NCFirebaseMessagingService"; + + // Firebase Messaging may apparently use two intent extras to specify a notification message. + // + // See the following fragments in https://github.com/firebase/firebase-android-sdk/blob/releases/m144_1.release/ + // firebase-messaging/src/main/java/com/google/firebase/messaging/FirebaseMessagingService.java#L223 + // firebase-messaging/src/main/java/com/google/firebase/messaging/NotificationParams.java#L419 + // firebase-messaging/src/main/java/com/google/firebase/messaging/Constants.java#L158 + // + // The "old" key is not exposed in com.google.firebase.messaging.Constants.MessageNotificationKeys, + // so we need to define it ourselves. + static final String ENABLE_NOTIFICATION_OLD = MessageNotificationKeys.NOTIFICATION_PREFIX_OLD + "e"; + static final String ENABLE_NOTIFICATION_NEW = MessageNotificationKeys.ENABLE_NOTIFICATION; + + @Override + public void onCreate() { + super.onCreate(); + AndroidInjection.inject(this); + } + + @Override + public void handleIntent(Intent intent) { + Log_OC.d(TAG, "handleIntent - extras: " + + ENABLE_NOTIFICATION_NEW + ": " + intent.getExtras().getString(ENABLE_NOTIFICATION_NEW) + ", " + + ENABLE_NOTIFICATION_OLD + ": " + intent.getExtras().getString(ENABLE_NOTIFICATION_OLD)); + + // When the app is in background and one of the ENABLE_NOTIFICATION or ENABLE_NOTIFICATION_OLD extras is set + // to "1" in the intent sent from the FCM system code to the FirebaseMessagingService in the application, + // the FCM library code that handles the intent DOES NOT invoke the onMessageReceived method. + // It just displays the notification by itself. + // + // In our case the original FCM message contains dummy values "NEW_NOTIFICATION" and we need to get the + // message in onMessageReceived to decrypt it. + // + // So we cheat here a little, by telling the FCM library that the notification flag is not set. + // + // Code below depends on implementation details of the firebase-messaging library (Firebase Android SDK). + // https://github.com/firebase/firebase-android-sdk/tree/master/firebase-messaging + + intent.removeExtra(ENABLE_NOTIFICATION_OLD); + intent.removeExtra(ENABLE_NOTIFICATION_NEW); + intent.putExtra(ENABLE_NOTIFICATION_NEW, "0"); + + super.handleIntent(intent); + } + + @Override + public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { + Log_OC.d(TAG, "onMessageReceived"); + final Map data = remoteMessage.getData(); + final String subject = data.get(NotificationWork.KEY_NOTIFICATION_SUBJECT); + final String signature = data.get(NotificationWork.KEY_NOTIFICATION_SIGNATURE); + if (subject != null && signature != null) { + backgroundJobManager.startNotificationJob(subject, signature); + } + } + + @Override + public void onNewToken(@NonNull String newToken) { + Log_OC.d(TAG, "onNewToken"); + super.onNewToken(newToken); + + if (!TextUtils.isEmpty(getResources().getString(R.string.push_server_url))) { + preferences.setPushToken(newToken); + PushUtils.pushRegistrationToServer(accountManager, preferences.getPushToken()); + } + } +} diff --git a/app/src/gplay/java/com/owncloud/android/utils/GooglePlayUtils.kt b/app/src/gplay/java/com/owncloud/android/utils/GooglePlayUtils.kt new file mode 100644 index 000000000000..9a307dddf609 --- /dev/null +++ b/app/src/gplay/java/com/owncloud/android/utils/GooglePlayUtils.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import android.app.Activity +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.owncloud.android.lib.common.utils.Log_OC + +object GooglePlayUtils { + private const val PLAY_SERVICES_RESOLUTION_REQUEST = 9000 + private const val TAG = "GooglePlayUtils" + + @JvmStatic + fun checkPlayServices(activity: Activity): Boolean { + val apiAvailability = GoogleApiAvailability.getInstance() + val resultCode = apiAvailability.isGooglePlayServicesAvailable(activity) + if (resultCode != ConnectionResult.SUCCESS) { + if (apiAvailability.isUserResolvableError(resultCode)) { + apiAvailability.getErrorDialog(activity, resultCode, PLAY_SERVICES_RESOLUTION_REQUEST)?.show() + } else { + Log_OC.i(TAG, "This device is not supported.") + activity.finish() + } + return false + } + return true + } +} diff --git a/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java b/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java new file mode 100644 index 000000000000..4e77b401556e --- /dev/null +++ b/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java @@ -0,0 +1,463 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017-2018 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils; + +import android.accounts.Account; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.content.Context; +import android.text.TextUtils; +import android.util.Base64; + +import com.google.gson.Gson; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.nextcloud.common.NextcloudClient; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.PushConfigurationState; +import com.owncloud.android.datamodel.SignatureVerification; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.notifications.RegisterAccountDeviceForNotificationsOperation; +import com.owncloud.android.lib.resources.notifications.RegisterAccountDeviceForProxyOperation; +import com.owncloud.android.lib.resources.notifications.UnregisterAccountDeviceForNotificationsOperation; +import com.owncloud.android.lib.resources.notifications.UnregisterAccountDeviceForProxyOperation; +import com.owncloud.android.lib.resources.notifications.models.PushResponse; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Locale; + +public final class PushUtils { + + public static final String KEY_PUSH = "push"; + private static final String TAG = "PushUtils"; + private static final String KEYPAIR_FOLDER = "nc-keypair"; + private static final String KEYPAIR_FILE_NAME = "push_key"; + private static final String KEYPAIR_PRIV_EXTENSION = ".priv"; + private static final String KEYPAIR_PUB_EXTENSION = ".pub"; + private static ArbitraryDataProvider arbitraryDataProvider; + + private PushUtils() { + } + + public static String generateSHA512Hash(String pushToken) { + MessageDigest messageDigest = null; + try { + messageDigest = MessageDigest.getInstance("SHA-512"); + messageDigest.update(pushToken.getBytes()); + return EncryptionUtils.bytesToHex(messageDigest.digest()); + } catch (NoSuchAlgorithmException e) { + Log_OC.d(TAG, "SHA-512 algorithm not supported"); + } + return ""; + } + + private static int generateRsa2048KeyPair() { + migratePushKeys(); + String keyPath = MainApp.getAppContext().getFilesDir().getAbsolutePath() + File.separator + + MainApp.getDataFolder() + File.separator + KEYPAIR_FOLDER; + + String privateKeyPath = keyPath + File.separator + KEYPAIR_FILE_NAME + KEYPAIR_PRIV_EXTENSION; + String publicKeyPath = keyPath + File.separator + KEYPAIR_FILE_NAME + KEYPAIR_PUB_EXTENSION; + File keyPathFile = new File(keyPath); + + if (!new File(privateKeyPath).exists() && !new File(publicKeyPath).exists()) { + try { + if (!keyPathFile.exists()) { + try { + Files.createDirectory(keyPathFile.toPath()); + } catch (IOException e) { + Log_OC.e(TAG, "Could not create directory: " + keyPathFile.getAbsolutePath(), e); + } + } + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + + KeyPair pair = keyGen.generateKeyPair(); + int statusPrivate = saveKeyToFile(pair.getPrivate(), privateKeyPath); + int statusPublic = saveKeyToFile(pair.getPublic(), publicKeyPath); + + if (statusPrivate == 0 && statusPublic == 0) { + // all went well + return 0; + } else { + return -2; + } + } catch (NoSuchAlgorithmException e) { + Log_OC.d(TAG, "RSA algorithm not supported"); + } + } else { + // we already have the key + return -1; + } + + // we failed to generate the key + return -2; + } + + private static void deleteRegistrationForAccount(Account account) { + Context context = MainApp.getAppContext(); + OwnCloudAccount ocAccount; + arbitraryDataProvider = new ArbitraryDataProviderImpl(MainApp.getAppContext()); + + try { + ocAccount = new OwnCloudAccount(account, context); + NextcloudClient mClient = OwnCloudClientManagerFactory.getDefaultSingleton(). + getNextcloudClientFor(ocAccount, context); + + RemoteOperationResult remoteOperationResult = + new UnregisterAccountDeviceForNotificationsOperation().execute(mClient); + + if (remoteOperationResult.getHttpCode() == HttpStatus.SC_ACCEPTED) { + String arbitraryValue; + if (!TextUtils.isEmpty(arbitraryValue = arbitraryDataProvider.getValue(account.name, KEY_PUSH))) { + Gson gson = new Gson(); + PushConfigurationState pushArbitraryData = gson.fromJson(arbitraryValue, + PushConfigurationState.class); + RemoteOperationResult unregisterResult = new UnregisterAccountDeviceForProxyOperation( + context.getResources().getString(R.string.push_server_url), + pushArbitraryData.getDeviceIdentifier(), + pushArbitraryData.getDeviceIdentifierSignature(), + pushArbitraryData.getUserPublicKey()).run(); + + if (unregisterResult.isSuccess()) { + arbitraryDataProvider.deleteKeyForAccount(account.name, KEY_PUSH); + } + } + } + } catch (com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException e) { + Log_OC.d(TAG, "Failed to find an account"); + } catch (AuthenticatorException e) { + Log_OC.d(TAG, "Failed via AuthenticatorException"); + } catch (IOException e) { + Log_OC.d(TAG, "Failed via IOException"); + } catch (OperationCanceledException e) { + Log_OC.d(TAG, "Failed via OperationCanceledException"); + } + } + + public static void pushRegistrationToServer(final UserAccountManager accountManager, final String token) { + arbitraryDataProvider = new ArbitraryDataProviderImpl(MainApp.getAppContext()); + + if (!TextUtils.isEmpty(MainApp.getAppContext().getResources().getString(R.string.push_server_url)) && + !TextUtils.isEmpty(token)) { + PushUtils.generateRsa2048KeyPair(); + String pushTokenHash = PushUtils.generateSHA512Hash(token).toLowerCase(Locale.ROOT); + PublicKey devicePublicKey = (PublicKey) PushUtils.readKeyFromFile(true); + if (devicePublicKey != null) { + byte[] publicKeyBytes = Base64.encode(devicePublicKey.getEncoded(), Base64.NO_WRAP); + String publicKey = new String(publicKeyBytes); + publicKey = publicKey.replaceAll("(.{64})", "$1\n"); + + publicKey = "-----BEGIN PUBLIC KEY-----\n" + publicKey + "\n-----END PUBLIC KEY-----\n"; + + Context context = MainApp.getAppContext(); + String providerValue; + PushConfigurationState accountPushData; + Gson gson = new Gson(); + for (Account account : accountManager.getAccounts()) { + providerValue = arbitraryDataProvider.getValue(account.name, KEY_PUSH); + if (!TextUtils.isEmpty(providerValue)) { + accountPushData = gson.fromJson(providerValue, + PushConfigurationState.class); + } else { + accountPushData = null; + } + + if (accountPushData != null && !accountPushData.getPushToken().equals(token) && + !accountPushData.isShouldBeDeleted() || + TextUtils.isEmpty(providerValue)) { + try { + OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); + NextcloudClient client = OwnCloudClientManagerFactory.getDefaultSingleton(). + getNextcloudClientFor(ocAccount, context); + + RemoteOperationResult remoteOperationResult = + new RegisterAccountDeviceForNotificationsOperation(pushTokenHash, + publicKey, + context.getResources().getString(R.string.push_server_url)) + .execute(client); + + if (remoteOperationResult.isSuccess()) { + PushResponse pushResponse = remoteOperationResult.getResultData(); + + RemoteOperationResult resultProxy = new RegisterAccountDeviceForProxyOperation( + context.getResources().getString(R.string.push_server_url), + token, pushResponse.getDeviceIdentifier(), + pushResponse.getSignature(), + pushResponse.getPublicKey(), + MainApp.getUserAgent()) + .run(); + + if (resultProxy.isSuccess()) { + PushConfigurationState pushArbitraryData = new PushConfigurationState(token, + pushResponse.getDeviceIdentifier(), pushResponse.getSignature(), + pushResponse.getPublicKey(), false); + arbitraryDataProvider.storeOrUpdateKeyValue(account.name, KEY_PUSH, + gson.toJson(pushArbitraryData)); + } + } else if (remoteOperationResult.getCode() == + RemoteOperationResult.ResultCode.ACCOUNT_USES_STANDARD_PASSWORD) { + arbitraryDataProvider.storeOrUpdateKeyValue(account.name, + UserAccountManager.ACCOUNT_USES_STANDARD_PASSWORD, "true"); + } + } catch (com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException e) { + Log_OC.d(TAG, "Failed to find an account"); + } catch (AuthenticatorException e) { + Log_OC.d(TAG, "Failed via AuthenticatorException"); + } catch (IOException e) { + Log_OC.d(TAG, "Failed via IOException"); + } catch (OperationCanceledException e) { + Log_OC.d(TAG, "Failed via OperationCanceledException"); + } + } else if (accountPushData != null && accountPushData.isShouldBeDeleted()) { + deleteRegistrationForAccount(account); + } + } + } + } + } + + public static Key readKeyFromFile(boolean readPublicKey) { + String keyPath = MainApp.getAppContext().getFilesDir().getAbsolutePath() + File.separator + + MainApp.getDataFolder() + File.separator + KEYPAIR_FOLDER; + + String privateKeyPath = keyPath + File.separator + KEYPAIR_FILE_NAME + KEYPAIR_PRIV_EXTENSION; + String publicKeyPath = keyPath + File.separator + KEYPAIR_FILE_NAME + KEYPAIR_PUB_EXTENSION; + + String path; + + if (readPublicKey) { + path = publicKeyPath; + } else { + path = privateKeyPath; + } + + FileInputStream fileInputStream = null; + try { + fileInputStream = new FileInputStream(path); + byte[] bytes = new byte[fileInputStream.available()]; + fileInputStream.read(bytes); + + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + if (readPublicKey) { + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(bytes); + return keyFactory.generatePublic(keySpec); + } else { + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(bytes); + return keyFactory.generatePrivate(keySpec); + } + + } catch (FileNotFoundException e) { + Log_OC.d(TAG, "Failed to find path while reading the Key"); + } catch (IOException e) { + Log_OC.d(TAG, "IOException while reading the key"); + } catch (InvalidKeySpecException e) { + Log_OC.d(TAG, "InvalidKeySpecException while reading the key"); + } catch (NoSuchAlgorithmException e) { + Log_OC.d(TAG, "RSA algorithm not supported"); + } finally { + if (fileInputStream != null) { + try { + fileInputStream.close(); + } catch (IOException e) { + Log_OC.e(TAG, "Error closing input stream during reading key from file", e); + } + } + } + + return null; + } + + private static int saveKeyToFile(Key key, String path) { + byte[] encoded = key.getEncoded(); + FileOutputStream keyFileOutputStream = null; + try { + if (!new File(path).exists()) { + File newFile = new File(path); + try { + Files.createDirectories(newFile.getParentFile().toPath()); + } catch (IOException e) { + Log_OC.e(TAG, "Could not create directory: " + newFile.getParentFile(), e); + } + Files.createFile(newFile.toPath()); + } + keyFileOutputStream = new FileOutputStream(path); + keyFileOutputStream.write(encoded); + return 0; + } catch (FileNotFoundException e) { + Log_OC.d(TAG, "Failed to save key to file"); + } catch (IOException e) { + Log_OC.d(TAG, "Failed to save key to file via IOException"); + } finally { + if (keyFileOutputStream != null) { + try { + keyFileOutputStream.close(); + } catch (IOException e) { + Log_OC.e(TAG, "Error closing input stream during reading key from file", e); + } + } + } + + return -1; + } + + public static void reinitKeys(final UserAccountManager accountManager) { + Context context = MainApp.getAppContext(); + Account[] accounts = accountManager.getAccounts(); + for (Account account : accounts) { + deleteRegistrationForAccount(account); + } + + String keyPath = context.getDir("nc-keypair", Context.MODE_PRIVATE).getAbsolutePath(); + File privateKeyFile = new File(keyPath, "push_key.priv"); + File publicKeyFile = new File(keyPath, "push_key.pub"); + + FileUtils.deleteQuietly(privateKeyFile); + FileUtils.deleteQuietly(publicKeyFile); + + AppPreferences preferences = AppPreferencesImpl.fromContext(context); + String pushToken = preferences.getPushToken(); + pushRegistrationToServer(accountManager, pushToken); + preferences.setKeysReInitEnabled(); + } + + private static void migratePushKeys() { + Context context = MainApp.getAppContext(); + AppPreferences preferences = AppPreferencesImpl.fromContext(context); + if (!preferences.isKeysMigrationEnabled()) { + String oldKeyPath = MainApp.getStoragePath() + File.separator + MainApp.getDataFolder() + + File.separator + "nc-keypair"; + File oldPrivateKeyFile = new File(oldKeyPath, "push_key.priv"); + File oldPublicKeyFile = new File(oldKeyPath, "push_key.pub"); + + String keyPath = context.getDir("nc-keypair", Context.MODE_PRIVATE).getAbsolutePath(); + File privateKeyFile = new File(keyPath, "push_key.priv"); + File publicKeyFile = new File(keyPath, "push_key.pub"); + + if ((privateKeyFile.exists() && publicKeyFile.exists()) || + (!oldPrivateKeyFile.exists() && !oldPublicKeyFile.exists())) { + preferences.setKeysMigrationEnabled(true); + } else { + if (oldPrivateKeyFile.exists()) { + FileStorageUtils.moveFile(oldPrivateKeyFile, privateKeyFile); + } + + if (oldPublicKeyFile.exists()) { + FileStorageUtils.moveFile(oldPublicKeyFile, publicKeyFile); + } + + if (privateKeyFile.exists() && publicKeyFile.exists()) { + preferences.setKeysMigrationEnabled(true); + } + } + } + } + + public static SignatureVerification verifySignature( + final Context context, + final UserAccountManager accountManager, + final byte[] signatureBytes, + final byte[] subjectBytes + ) { + Signature signature; + PublicKey publicKey; + + Account[] accounts = accountManager.getAccounts(); + + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); + String arbitraryValue; + Gson gson = new Gson(); + PushConfigurationState pushArbitraryData; + + try { + signature = Signature.getInstance("SHA512withRSA"); + if (accounts.length > 0) { + for (Account account : accounts) { + if (!TextUtils.isEmpty(arbitraryValue = arbitraryDataProvider.getValue(account.name, KEY_PUSH))) { + pushArbitraryData = gson.fromJson(arbitraryValue, PushConfigurationState.class); + if (!pushArbitraryData.isShouldBeDeleted()) { + publicKey = (PublicKey) readKeyFromString(true, pushArbitraryData.getUserPublicKey()); + signature.initVerify(publicKey); + signature.update(subjectBytes); + if (signature.verify(signatureBytes)) { + return new SignatureVerification(true, account); + } + } + } + } + } + } catch (NoSuchAlgorithmException e) { + Log_OC.d(TAG, "No such algorithm"); + } catch (InvalidKeyException e) { + Log_OC.d(TAG, "Invalid key while trying to verify"); + } catch (SignatureException e) { + Log_OC.d(TAG, "Signature exception while trying to verify"); + } + + return new SignatureVerification(false, null); + } + + private static Key readKeyFromString(boolean readPublicKey, String keyString) { + String modifiedKey; + if (readPublicKey) { + modifiedKey = keyString.replaceAll("\\n", "").replace("-----BEGIN PUBLIC KEY-----", + "").replace("-----END PUBLIC KEY-----", ""); + } else { + modifiedKey = keyString.replaceAll("\\n", "").replace("-----BEGIN PRIVATE KEY-----", + "").replace("-----END PRIVATE KEY-----", ""); + } + + KeyFactory keyFactory; + try { + keyFactory = KeyFactory.getInstance("RSA"); + if (readPublicKey) { + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.decode(modifiedKey, Base64.DEFAULT)); + return keyFactory.generatePublic(keySpec); + } else { + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.decode(modifiedKey, Base64.DEFAULT)); + return keyFactory.generatePrivate(keySpec); + } + } catch (NoSuchAlgorithmException e) { + Log_OC.d("TAG", "No such algorithm while reading key from string"); + } catch (InvalidKeySpecException e) { + Log_OC.d("TAG", "Invalid key spec while reading key from string"); + } + + return null; + } +} diff --git a/app/src/gplay/java/com/owncloud/android/utils/SecurityUtils.java b/app/src/gplay/java/com/owncloud/android/utils/SecurityUtils.java new file mode 100644 index 000000000000..0ee14db82385 --- /dev/null +++ b/app/src/gplay/java/com/owncloud/android/utils/SecurityUtils.java @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils; + +import android.content.Intent; + +import com.google.android.gms.security.ProviderInstaller; +import com.owncloud.android.MainApp; + +public class SecurityUtils implements ProviderInstaller.ProviderInstallListener { + public SecurityUtils() { + ProviderInstaller.installIfNeededAsync(MainApp.getAppContext(), this); + } + + @Override + public void onProviderInstalled() { + // Does nothing + } + + @Override + public void onProviderInstallFailed(int i, Intent intent) { + // Does nothing + } +} diff --git a/app/src/gplay/res/values/setup.xml b/app/src/gplay/res/values/setup.xml new file mode 100644 index 000000000000..83fadc4e35d1 --- /dev/null +++ b/app/src/gplay/res/values/setup.xml @@ -0,0 +1,22 @@ + + + + + https://push-notifications.nextcloud.com + + 829118773643-cq33cmhv7mnv7iq8mjv6rt7t15afc70k.apps.googleusercontent.com + https://nextcloud-a7dea.firebaseio.com + 829118773643 + AIzaSyAWIyOcLafaFp8PFL61h64cy1NNZW2cU_s + 1:829118773643:android:512449826e931d0e + AIzaSyAWIyOcLafaFp8PFL61h64cy1NNZW2cU_s + nextcloud-a7dea.appspot.com + nextcloud-a7dea + + + diff --git a/app/src/huawei/AndroidManifest.xml b/app/src/huawei/AndroidManifest.xml new file mode 100644 index 000000000000..222212633860 --- /dev/null +++ b/app/src/huawei/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/huawei/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt b/app/src/huawei/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt new file mode 100644 index 000000000000..34224244fc09 --- /dev/null +++ b/app/src/huawei/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.appReview + +import androidx.appcompat.app.AppCompatActivity +import com.nextcloud.appReview.InAppReviewHelper +import com.nextcloud.client.preferences.AppPreferences + +class InAppReviewHelperImpl(appPreferences: AppPreferences) : InAppReviewHelper { + override fun resetAndIncrementAppRestartCounter() { + } + + override fun showInAppReview(activity: AppCompatActivity) { + } +} diff --git a/app/src/huawei/java/com/nextcloud/client/di/VariantComponentsModule.java b/app/src/huawei/java/com/nextcloud/client/di/VariantComponentsModule.java new file mode 100644 index 000000000000..eba1426595e8 --- /dev/null +++ b/app/src/huawei/java/com/nextcloud/client/di/VariantComponentsModule.java @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di; + + +import dagger.Module; + +@Module +abstract class VariantComponentsModule { +} diff --git a/app/src/huawei/java/com/owncloud/android/utils/PushUtils.java b/app/src/huawei/java/com/owncloud/android/utils/PushUtils.java new file mode 100644 index 000000000000..bf1949a33a72 --- /dev/null +++ b/app/src/huawei/java/com/owncloud/android/utils/PushUtils.java @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils; + +import android.content.Context; + +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.owncloud.android.MainApp; +import com.owncloud.android.datamodel.SignatureVerification; + +import java.security.Key; + +public final class PushUtils { + public static final String KEY_PUSH = "push"; + + private PushUtils() { + } + + public static void pushRegistrationToServer(final UserAccountManager accountManager, final String pushToken) { + // do nothing + } + + public static void reinitKeys(UserAccountManager accountManager) { + Context context = MainApp.getAppContext(); + AppPreferencesImpl.fromContext(context).setKeysReInitEnabled(); + } + + public static Key readKeyFromFile(boolean readPublicKey) { + return null; + } + + public static SignatureVerification verifySignature( + final Context context, + final UserAccountManager accountManager, + final byte[] signatureBytes, + final byte[] subjectBytes + ) { + return null; + } + +} diff --git a/app/src/huawei/java/com/owncloud/android/utils/SecurityUtils.java b/app/src/huawei/java/com/owncloud/android/utils/SecurityUtils.java new file mode 100644 index 000000000000..97b19a2ce0dd --- /dev/null +++ b/app/src/huawei/java/com/owncloud/android/utils/SecurityUtils.java @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils; + +public class SecurityUtils { +} diff --git a/app/src/huawei/res/values/bools.xml b/app/src/huawei/res/values/bools.xml new file mode 100644 index 000000000000..7e8bb547bed1 --- /dev/null +++ b/app/src/huawei/res/values/bools.xml @@ -0,0 +1,11 @@ + + + + false + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..a500a6b8f596 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,699 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/aidl/com/nextcloud/android/sso/aidl/IInputStreamService.aidl b/app/src/main/aidl/com/nextcloud/android/sso/aidl/IInputStreamService.aidl new file mode 100644 index 000000000000..c3a7cb7c9d02 --- /dev/null +++ b/app/src/main/aidl/com/nextcloud/android/sso/aidl/IInputStreamService.aidl @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2008-2011 CommonsWare, LLC + * SPDX-License-Identifier: Apache-2.0 + * + * From _The Busy Coder's Guide to Advanced Android Development_ + * http://commonsware.com/AdvAndroid + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso.aidl; + +// Declare the interface. +interface IInputStreamService { + + ParcelFileDescriptor performNextcloudRequestAndBodyStream(in ParcelFileDescriptor input, + in ParcelFileDescriptor requestBodyParcelFileDescriptor); + + ParcelFileDescriptor performNextcloudRequest(in ParcelFileDescriptor input); + + ParcelFileDescriptor performNextcloudRequestAndBodyStreamV2(in ParcelFileDescriptor input, + in ParcelFileDescriptor requestBodyParcelFileDescriptor); + + ParcelFileDescriptor performNextcloudRequestV2(in ParcelFileDescriptor input); +} diff --git a/app/src/main/ic_launcher-web-round.png b/app/src/main/ic_launcher-web-round.png new file mode 100644 index 000000000000..dc58ff797add Binary files /dev/null and b/app/src/main/ic_launcher-web-round.png differ diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 000000000000..5ece49f5452a Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/java/com/nextcloud/android/files/FileLockingHelper.kt b/app/src/main/java/com/nextcloud/android/files/FileLockingHelper.kt new file mode 100644 index 000000000000..5e3c7a66a964 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/files/FileLockingHelper.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.files + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.files.model.FileLockType + +object FileLockingHelper { + /** + * Checks whether the given `userId` can unlock the [OCFile]. + */ + @JvmStatic + fun canUserUnlockFile(userId: String, file: OCFile): Boolean { + if (!file.isLocked || file.lockOwnerId == null || file.lockType != FileLockType.MANUAL) { + return false + } + return file.lockOwnerId == userId + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/Constants.java b/app/src/main/java/com/nextcloud/android/sso/Constants.java new file mode 100644 index 000000000000..86dd2f8a78e7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/Constants.java @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 David Luhmer + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso; + +public final class Constants { + // Authenticator related constants + public static final String SSO_USER_ID = "user_id"; + public static final String SSO_TOKEN = "token"; + public static final String SSO_SERVER_URL = "server_url"; + public static final String SSO_SHARED_PREFERENCE = "single-sign-on"; + public static final String NEXTCLOUD_SSO_EXCEPTION = "NextcloudSsoException"; + public static final String NEXTCLOUD_SSO = "NextcloudSSO"; + public static final String NEXTCLOUD_FILES_ACCOUNT = "NextcloudFilesAccount"; + public static final String DELIMITER = "_"; + + // Custom Exceptions + public static final String EXCEPTION_INVALID_TOKEN = "CE_1"; + public static final String EXCEPTION_ACCOUNT_NOT_FOUND = "CE_2"; + public static final String EXCEPTION_UNSUPPORTED_METHOD = "CE_3"; + public static final String EXCEPTION_INVALID_REQUEST_URL = "CE_4"; + public static final String EXCEPTION_HTTP_REQUEST_FAILED = "CE_5"; + public static final String EXCEPTION_ACCOUNT_ACCESS_DECLINED = "CE_6"; + + private Constants() { + // No instance + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java new file mode 100644 index 000000000000..31e061b5778c --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java @@ -0,0 +1,527 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso; + +import android.accounts.Account; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Binder; +import android.os.ParcelFileDescriptor; +import android.text.TextUtils; + +import com.nextcloud.android.sso.aidl.IInputStreamService; +import com.nextcloud.android.sso.aidl.NextcloudRequest; +import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil; +import com.nextcloud.client.account.UserAccountManager; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManager; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.EncryptionUtils; + +import org.apache.commons.httpclient.HttpConnection; +import org.apache.commons.httpclient.HttpMethodBase; +import org.apache.commons.httpclient.HttpState; +import org.apache.commons.httpclient.NameValuePair; +import org.apache.commons.httpclient.methods.DeleteMethod; +import org.apache.commons.httpclient.methods.GetMethod; +import org.apache.commons.httpclient.methods.HeadMethod; +import org.apache.commons.httpclient.methods.InputStreamRequestEntity; +import org.apache.commons.httpclient.methods.PostMethod; +import org.apache.commons.httpclient.methods.PutMethod; +import org.apache.commons.httpclient.methods.RequestEntity; +import org.apache.commons.httpclient.methods.StringRequestEntity; +import org.apache.jackrabbit.webdav.DavConstants; +import org.apache.jackrabbit.webdav.client.methods.MkColMethod; +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod; +import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import androidx.annotation.VisibleForTesting; + +import static com.nextcloud.android.sso.Constants.DELIMITER; +import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND; +import static com.nextcloud.android.sso.Constants.EXCEPTION_HTTP_REQUEST_FAILED; +import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_REQUEST_URL; +import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_TOKEN; +import static com.nextcloud.android.sso.Constants.EXCEPTION_UNSUPPORTED_METHOD; +import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; + + +/** + * Stream binder to pass usable InputStreams across the process boundary in Android. + */ +public class InputStreamBinder extends IInputStreamService.Stub { + + private final static String TAG = "InputStreamBinder"; + private static final String CONTENT_TYPE_APPLICATION_JSON = "application/json"; + private static final String CHARSET_UTF8 = "UTF-8"; + + private static final int HTTP_STATUS_CODE_OK = 200; + private static final int HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300; + + private static final char PATH_SEPARATOR = '/'; + private static final int ZERO_LENGTH = 0; + private Context context; + private UserAccountManager accountManager; + + public InputStreamBinder(Context context, UserAccountManager accountManager) { + this.context = context; + this.accountManager = accountManager; + } + + public ParcelFileDescriptor performNextcloudRequestV2(ParcelFileDescriptor input) { + return performNextcloudRequestAndBodyStreamV2(input, null); + } + + public ParcelFileDescriptor performNextcloudRequestAndBodyStreamV2( + ParcelFileDescriptor input, + ParcelFileDescriptor requestBodyParcelFileDescriptor) { + // read the input + final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input); + + final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ? + new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null; + Exception exception = null; + Response response = new Response(); + + try { + // Start request and catch exceptions + NextcloudRequest request = deserializeObjectAndCloseStream(is); + response = processRequestV2(request, requestBodyInputStream); + } catch (Exception e) { + Log_OC.e(TAG, "Error during Nextcloud request", e); + exception = e; + } + + try { + // Write exception to the stream followed by the actual network stream + InputStream exceptionStream = serializeObjectToInputStreamV2(exception, response.getPlainHeadersString()); + InputStream resultStream = new java.io.SequenceInputStream(exceptionStream, response.getBody()); + + return ParcelFileDescriptorUtil.pipeFrom(resultStream, + thread -> Log_OC.d(TAG, "Done sending result"), + response.getMethod()); + } catch (IOException e) { + Log_OC.e(TAG, "Error while sending response back to client app", e); + } + return null; + } + + public ParcelFileDescriptor performNextcloudRequest(ParcelFileDescriptor input) { + return performNextcloudRequestAndBodyStream(input, null); + } + + public ParcelFileDescriptor performNextcloudRequestAndBodyStream( + ParcelFileDescriptor input, + ParcelFileDescriptor requestBodyParcelFileDescriptor) { + // read the input + final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input); + + final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ? + new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null; + Exception exception = null; + HttpMethodBase httpMethod = null; + InputStream httpStream = new InputStream() { + @Override + public int read() { + return ZERO_LENGTH; + } + }; + + try { + // Start request and catch exceptions + NextcloudRequest request = deserializeObjectAndCloseStream(is); + httpMethod = processRequest(request, requestBodyInputStream); + httpStream = httpMethod.getResponseBodyAsStream(); + } catch (Exception e) { + Log_OC.e(TAG, "Error during Nextcloud request", e); + exception = e; + } + + try { + // Write exception to the stream followed by the actual network stream + InputStream exceptionStream = serializeObjectToInputStream(exception); + InputStream resultStream; + if (httpStream != null) { + resultStream = new java.io.SequenceInputStream(exceptionStream, httpStream); + } else { + resultStream = exceptionStream; + } + return ParcelFileDescriptorUtil.pipeFrom(resultStream, + thread -> Log_OC.d(TAG, "Done sending result"), + httpMethod); + } catch (IOException e) { + Log_OC.e(TAG, "Error while sending response back to client app", e); + } + return null; + } + + private ByteArrayInputStream serializeObjectToInputStreamV2(Exception exception, String headers) { + byte[] baosByteArray = new byte[0]; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(exception); + oos.writeObject(headers); + oos.flush(); + oos.close(); + + baosByteArray = baos.toByteArray(); + } catch (IOException e) { + Log_OC.e(TAG, "Error while sending response back to client app", e); + } + + return new ByteArrayInputStream(baosByteArray); + } + + private ByteArrayInputStream serializeObjectToInputStream(T obj) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + oos.writeObject(obj); + oos.flush(); + oos.close(); + return new ByteArrayInputStream(baos.toByteArray()); + } + + private T deserializeObjectAndCloseStream(InputStream is) throws IOException, + ClassNotFoundException { + ObjectInputStream ois = new ObjectInputStream(is); + T result = (T) ois.readObject(); + is.close(); + ois.close(); + return result; + } + + public class NCPropFindMethod extends PropFindMethod { + NCPropFindMethod(String uri, int propfindType, int depth) throws IOException { + super(uri, propfindType, new DavPropertyNameSet(), depth); + } + + @Override + protected void processResponseBody(HttpState httpState, HttpConnection httpConnection) { + // Do not process the response body here. Instead pass it on to client app. + } + } + + private HttpMethodBase buildMethod(NextcloudRequest request, Uri baseUri, InputStream requestBodyInputStream) + throws IOException { + String requestUrl = baseUri + request.getUrl(); + HttpMethodBase method; + switch (request.getMethod()) { + case "GET": + method = new GetMethod(requestUrl); + break; + + case "POST": + method = new PostMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PostMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PostMethod) method).setRequestEntity(requestEntity); + } + break; + + case "PATCH": + method = new PatchMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PatchMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PatchMethod) method).setRequestEntity(requestEntity); + } + break; + + case "PUT": + method = new PutMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PutMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PutMethod) method).setRequestEntity(requestEntity); + } + break; + + case "DELETE": + method = new DeleteMethod(requestUrl); + break; + + case "PROPFIND": + method = new NCPropFindMethod(requestUrl, DavConstants.PROPFIND_ALL_PROP, DavConstants.DEPTH_1); + if (request.getRequestBody() != null) { + //text/xml; charset=UTF-8 is taken from XmlRequestEntity... Should be application/xml + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + "text/xml; charset=UTF-8", + CHARSET_UTF8); + ((PropFindMethod) method).setRequestEntity(requestEntity); + } + break; + + case "MKCOL": + method = new MkColMethod(requestUrl); + break; + + case "HEAD": + method = new HeadMethod(requestUrl); + break; + + default: + throw new UnsupportedOperationException(EXCEPTION_UNSUPPORTED_METHOD); + + } + return method; + } + + private HttpMethodBase processRequest(final NextcloudRequest request, final InputStream requestBodyInputStream) + throws UnsupportedOperationException, + com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, + OperationCanceledException, AuthenticatorException, IOException { + Account account = accountManager.getAccountByName(request.getAccountName()); + if (account == null) { + throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); + } + + // Validate token + if (!isValid(request)) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + // Validate URL + if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) { + throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, + new IllegalStateException("URL need to start with a /")); + } + + OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton(); + OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); + OwnCloudClient client = ownCloudClientManager.getClientFor(ocAccount, context); + + HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); + + if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { + method.setQueryString(convertListToNVP(request.getParameterV2())); + } else { + method.setQueryString(convertMapToNVP(request.getParameter())); + } + method.addRequestHeader("OCS-APIREQUEST", "true"); + + for (Map.Entry> header : request.getHeader().entrySet()) { + // https://stackoverflow.com/a/3097052 + method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); + + if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) { + throw new IllegalStateException( + "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " + + "Please remove the header before making a request"); + } + } + + client.setFollowRedirects(request.isFollowRedirects()); + int status = client.executeMethod(method); + + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success + if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { + return method; + } else { + InputStream inputStream = method.getResponseBodyAsStream(); + String total = "No response body"; + + // If response body is available + if (inputStream != null) { + total = inputStreamToString(inputStream); + Log_OC.e(TAG, total); + } + + method.releaseConnection(); + throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED, + new IllegalStateException(String.valueOf(status), + new IllegalStateException(total))); + } + } + + private Response processRequestV2(final NextcloudRequest request, final InputStream requestBodyInputStream) + throws UnsupportedOperationException, + com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, + OperationCanceledException, AuthenticatorException, IOException { + Account account = accountManager.getAccountByName(request.getAccountName()); + if (account == null) { + throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); + } + + // Validate token + if (!isValid(request)) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + // Validate URL + if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) { + throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, + new IllegalStateException("URL need to start with a /")); + } + + OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton(); + OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); + OwnCloudClient client = ownCloudClientManager.getClientFor(ocAccount, context); + + HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); + + if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { + method.setQueryString(convertListToNVP(request.getParameterV2())); + } else { + method.setQueryString(convertMapToNVP(request.getParameter())); + } + + method.addRequestHeader("OCS-APIREQUEST", "true"); + + for (Map.Entry> header : request.getHeader().entrySet()) { + // https://stackoverflow.com/a/3097052 + method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); + + if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) { + throw new IllegalStateException( + "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " + + "Please remove the header before making a request"); + } + } + + client.setFollowRedirects(request.isFollowRedirects()); + int status = client.executeMethod(method); + + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success + if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { + return new Response(method); + } else { + InputStream inputStream = method.getResponseBodyAsStream(); + String total = "No response body"; + + // If response body is available + if (inputStream != null) { + total = inputStreamToString(inputStream); + Log_OC.e(TAG, total); + } + + method.releaseConnection(); + throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED, + new IllegalStateException(String.valueOf(status), + new IllegalStateException(total))); + } + } + + private boolean isValid(NextcloudRequest request) { + String[] callingPackageNames = context.getPackageManager().getPackagesForUid(Binder.getCallingUid()); + + SharedPreferences sharedPreferences = context.getSharedPreferences(SSO_SHARED_PREFERENCE, + Context.MODE_PRIVATE); + for (String callingPackageName : callingPackageNames) { + String hash = sharedPreferences.getString(callingPackageName + DELIMITER + request.getAccountName(), ""); + if (hash.isEmpty()) + continue; + if (validateToken(hash, request.getToken())) { + return true; + } + } + return false; + } + + private boolean validateToken(String hash, String token) { + if (!hash.contains("$")) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + String salt = hash.split("\\$")[1]; // TODO extract "$" + + String newHash = EncryptionUtils.generateSHA512(token, salt); + + // As discussed with Lukas R. at the Nextcloud Conf 2018, always compare whole strings + // and don't exit prematurely if the string does not match anymore to prevent timing-attacks + return isEqual(hash.getBytes(), newHash.getBytes()); + } + + // Taken from http://codahale.com/a-lesson-in-timing-attacks/ + private static boolean isEqual(byte[] a, byte[] b) { + if (a.length != b.length) { + return false; + } + + int result = 0; + for (int i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result == 0; + } + + private static String inputStreamToString(InputStream inputStream) { + try { + StringBuilder total = new StringBuilder(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + String line = reader.readLine(); + while (line != null) { + total.append(line).append('\n'); + line = reader.readLine(); + } + return total.toString(); + } catch (Exception e) { + return e.getMessage(); + } + } + + @VisibleForTesting + public static NameValuePair[] convertMapToNVP(Map map) { + final var nvp = new NameValuePair[map.size()]; + int i = 0; + + for (Map.Entry entry : map.entrySet()) { + final var nameValuePair = new NameValuePair(entry.getKey(), entry.getValue()); + nvp[i] = nameValuePair; + i++; + } + + return nvp; + } + + @VisibleForTesting + public static NameValuePair[] convertListToNVP(Collection list) { + NameValuePair[] nvp = new NameValuePair[list.size()]; + int i = 0; + for (QueryParam pair : list) { + nvp[i] = new NameValuePair(pair.key, pair.value); + i++; + } + return nvp; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java b/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java new file mode 100644 index 000000000000..34c5e8b24f05 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java @@ -0,0 +1,100 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Timo Triebensky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso; + +import org.apache.commons.httpclient.methods.ByteArrayRequestEntity; +import org.apache.commons.httpclient.methods.EntityEnclosingMethod; +import org.apache.commons.httpclient.methods.PostMethod; +import org.apache.commons.httpclient.methods.RequestEntity; +import org.apache.commons.httpclient.util.EncodingUtil; + +import java.util.Vector; + +public class PatchMethod extends PostMethod { + + /** + * The buffered request body consisting of NameValuePairs. + */ + private Vector params = new Vector(); + + /** + * No-arg constructor. + */ + public PatchMethod() { + super(); + } + + /** + * Constructor specifying a URI. + * + * @param uri either an absolute or relative URI + */ + public PatchMethod(String uri) { + super(uri); + } + + /** + * Returns "PATCH". + * + * @return "PATCH" + * @since 2.0 + */ + @Override + public String getName() { + return "PATCH"; + } + + /** + * Returns true if there is a request body to be sent. + * + * @return boolean + * @since 2.0beta1 + */ + protected boolean hasRequestContent() { + if (!this.params.isEmpty()) { + return true; + } else { + return super.hasRequestContent(); + } + } + + /** + * Clears request body. + * + * @since 2.0beta1 + */ + protected void clearRequestBody() { + this.params.clear(); + super.clearRequestBody(); + } + + /** + * Generates a request entity from the patch parameters, if present. Calls {@link + * EntityEnclosingMethod#generateRequestBody()} if parameters have not been set. + * + * @since 3.0 + */ + protected RequestEntity generateRequestEntity() { + if (!this.params.isEmpty()) { + // Use a ByteArrayRequestEntity instead of a StringRequestEntity. + // This is to avoid potential encoding issues. Form url encoded strings + // are ASCII by definition but the content type may not be. Treating the content + // as bytes allows us to keep the current charset without worrying about how + // this charset will effect the encoding of the form url encoded string. + String content = EncodingUtil.formUrlEncode(getParameters(), getRequestCharSet()); + return new ByteArrayRequestEntity( + EncodingUtil.getAsciiBytes(content), + FORM_URL_ENCODED_CONTENT_TYPE + ); + } else { + return super.generateRequestEntity(); + } + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java b/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java new file mode 100644 index 000000000000..07c23c6b4b94 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; + +public class PlainHeader implements Serializable { + private static final long serialVersionUID = 3284979177401282512L; + + private String name; + private String value; + + PlainHeader(String name, String value) { + this.name = name; + this.value = value; + } + + private void writeObject(ObjectOutputStream oos) throws IOException { + oos.writeObject(name); + oos.writeObject(value); + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + name = (String) in.readObject(); + value = (String) in.readObject(); + } + + public String getName() { + return this.name; + } + + public String getValue() { + return this.value; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/QueryParam.java b/app/src/main/java/com/nextcloud/android/sso/QueryParam.java new file mode 100644 index 000000000000..21f7644972e1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/QueryParam.java @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso; + +import java.io.Serializable; + +public class QueryParam implements Serializable { + private static final long serialVersionUID = 21523240203234211L; // must be same as in SSO project + + public String key; + public String value; + + public QueryParam(String key, String value) { + this.key = key; + this.value = value; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/Response.java b/app/src/main/java/com/nextcloud/android/sso/Response.java new file mode 100644 index 000000000000..2402b8b1dbfb --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/Response.java @@ -0,0 +1,59 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso; + +import com.google.gson.Gson; + +import org.apache.commons.httpclient.Header; +import org.apache.commons.httpclient.HttpMethodBase; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +public class Response { + private InputStream body; + private Header[] headers; + private HttpMethodBase method; + + public Response() { + headers = new Header[0]; + body = new InputStream() { + @Override + public int read() { + return 0; + } + }; + } + + public Response(HttpMethodBase methodBase) throws IOException { + this.method = methodBase; + this.body = methodBase.getResponseBodyAsStream(); + this.headers = methodBase.getResponseHeaders(); + } + + public String getPlainHeadersString() { + List arrayList = new ArrayList<>(headers.length); + + for (Header header : headers) { + arrayList.add(new PlainHeader(header.getName(), header.getValue())); + } + + Gson gson = new Gson(); + return gson.toJson(arrayList); + } + + public InputStream getBody() { + return this.body; + } + + public HttpMethodBase getMethod() { + return method; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/aidl/IThreadListener.java b/app/src/main/java/com/nextcloud/android/sso/aidl/IThreadListener.java new file mode 100644 index 000000000000..cf4ab01b3b69 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/aidl/IThreadListener.java @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso.aidl; + +public interface IThreadListener { + + void onThreadFinished(final Thread thread); +} diff --git a/app/src/main/java/com/nextcloud/android/sso/aidl/NextcloudRequest.java b/app/src/main/java/com/nextcloud/android/sso/aidl/NextcloudRequest.java new file mode 100644 index 000000000000..2308e01188ab --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/aidl/NextcloudRequest.java @@ -0,0 +1,145 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-FileCopyrightText: 2017 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso.aidl; + +import com.nextcloud.android.sso.QueryParam; + +import java.io.Serializable; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class NextcloudRequest implements Serializable { + + private static final long serialVersionUID = 215521212534240L; //assign a long value + + private String method; + private Map> header = new HashMap<>(); + private Map parameter = new HashMap<>(); + private final Collection parameterV2 = new LinkedList<>(); + private String requestBody; + private String url; + private String token; + private String packageName; + private String accountName; + private boolean followRedirects; + + private NextcloudRequest() { } + + public static class Builder { + private NextcloudRequest ncr; + + public Builder() { + ncr = new NextcloudRequest(); + } + + public NextcloudRequest build() { + return ncr; + } + + public Builder setMethod(String method) { + ncr.method = method; + return this; + } + + public Builder setHeader(Map> header) { + ncr.header = header; + return this; + } + + public Builder setParameter(Map parameter) { + ncr.parameter = parameter; + return this; + } + + public Builder setRequestBody(String requestBody) { + ncr.requestBody = requestBody; + return this; + } + + public Builder setUrl(String url) { + ncr.url = url; + return this; + } + + public Builder setToken(String token) { + ncr.token = token; + return this; + } + + public Builder setAccountName(String accountName) { + ncr.accountName = accountName; + return this; + } + + /** + * Default value: true + * @param followRedirects + * @return + */ + public Builder setFollowRedirects(boolean followRedirects) { + ncr.followRedirects = followRedirects; + return this; + } + } + + public String getMethod() { + return this.method; + } + + public Map> getHeader() { + return this.header; + } + + public Map getParameter() { + return this.parameter; + } + + public String getRequestBody() { + return this.requestBody; + } + + public String getUrl() { + return this.url; + } + + public String getToken() { + return this.token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getPackageName() { + return this.packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getAccountName() { + return this.accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public boolean isFollowRedirects() { + return this.followRedirects; + } + + public Collection getParameterV2() { + return parameterV2; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/aidl/ParcelFileDescriptorUtil.java b/app/src/main/java/com/nextcloud/android/sso/aidl/ParcelFileDescriptorUtil.java new file mode 100644 index 000000000000..fbbebdbc7ec5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/aidl/ParcelFileDescriptorUtil.java @@ -0,0 +1,91 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso.aidl; + +import android.os.ParcelFileDescriptor; + +import com.owncloud.android.lib.common.utils.Log_OC; + +import org.apache.commons.httpclient.HttpMethodBase; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public final class ParcelFileDescriptorUtil { + + private ParcelFileDescriptorUtil() { } + + public static ParcelFileDescriptor pipeFrom(InputStream inputStream, + IThreadListener listener, + HttpMethodBase method) + throws IOException { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + ParcelFileDescriptor readSide = pipe[0]; + ParcelFileDescriptor writeSide = pipe[1]; + + // start the transfer thread + new TransferThread(inputStream, + new ParcelFileDescriptor.AutoCloseOutputStream(writeSide), + listener, + method) + .start(); + + return readSide; + } + + public static class TransferThread extends Thread { + private static final String TAG = TransferThread.class.getCanonicalName(); + private final InputStream inputStream; + private final OutputStream outputStream; + private final IThreadListener threadListener; + private final HttpMethodBase httpMethod; + + TransferThread(InputStream in, OutputStream out, IThreadListener listener, HttpMethodBase method) { + super("ParcelFileDescriptor Transfer Thread"); + inputStream = in; + outputStream = out; + threadListener = listener; + httpMethod = method; + setDaemon(true); + } + + @Override + public void run() { + byte[] buf = new byte[1024]; + int len; + + try { + while ((len = inputStream.read(buf)) > 0) { + outputStream.write(buf, 0, len); + } + outputStream.flush(); // just to be safe + } catch (IOException e) { + Log_OC.e(TAG, "writing failed: " + e.getMessage()); + } finally { + try { + inputStream.close(); + } catch (IOException e) { + Log_OC.e(TAG, e.getMessage()); + } + try { + outputStream.close(); + } catch (IOException e) { + Log_OC.e(TAG, e.getMessage()); + } + } + if (threadListener != null) { + threadListener.onThreadFinished(this); + } + + if (httpMethod != null) { + Log_OC.i(TAG, "releaseConnection"); + httpMethod.releaseConnection(); + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/appReview/AppReviewShownModel.kt b/app/src/main/java/com/nextcloud/appReview/AppReviewShownModel.kt new file mode 100644 index 000000000000..bd0acc0b4cd1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/appReview/AppReviewShownModel.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.appReview + +data class AppReviewShownModel( + var firstShowYear: String?, + var appRestartCount: Int, + var reviewShownCount: Int, + var lastReviewShownDate: String? +) diff --git a/app/src/main/java/com/nextcloud/appReview/InAppReviewHelper.kt b/app/src/main/java/com/nextcloud/appReview/InAppReviewHelper.kt new file mode 100644 index 000000000000..34a054b14c13 --- /dev/null +++ b/app/src/main/java/com/nextcloud/appReview/InAppReviewHelper.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.appReview + +import androidx.appcompat.app.AppCompatActivity + +interface InAppReviewHelper { + + /** + * method to be called from Application onCreate() method to work properly + * since we have to capture the app restarts Application is the best place to do that + * this method will do the following: + * 1. Reset the @see AppReviewModel with the current year (yyyy), + * if the app is launched first time or if the year has changed. + * 2. If the year is same then it will only increment the appRestartCount + */ + fun resetAndIncrementAppRestartCounter() + + /** + * method to be called from Activity onResume() method + * this method will check the following conditions: + * 1. if the minimum app restarts happened + * 2. if the year is current + * 3. if maximum review dialog is shown or not + * once all the conditions satisfies it will trigger In-App Review manager to show the flow + */ + fun showInAppReview(activity: AppCompatActivity) +} diff --git a/app/src/main/java/com/nextcloud/appReview/InAppReviewModule.kt b/app/src/main/java/com/nextcloud/appReview/InAppReviewModule.kt new file mode 100644 index 000000000000..c2f4e92b94bd --- /dev/null +++ b/app/src/main/java/com/nextcloud/appReview/InAppReviewModule.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.appReview + +import com.nextcloud.android.appReview.InAppReviewHelperImpl +import com.nextcloud.client.preferences.AppPreferences +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +class InAppReviewModule { + + @Provides + @Singleton + internal fun providesInAppReviewHelper(appPreferences: AppPreferences): InAppReviewHelper = + InAppReviewHelperImpl(appPreferences) +} diff --git a/app/src/main/java/com/nextcloud/client/NominatimClient.kt b/app/src/main/java/com/nextcloud/client/NominatimClient.kt new file mode 100644 index 000000000000..7d8f35b07fb2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/NominatimClient.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 ZetaTom + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.owncloud.android.MainApp +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.HttpURLConnection.HTTP_OK +import java.net.URLEncoder + +class NominatimClient constructor(geocoderBaseUrl: String, email: String) { + private val client = OkHttpClient() + private val gson = Gson() + private val reverseUrl = "${geocoderBaseUrl}reverse?format=jsonv2&email=${URLEncoder.encode(email, ENCODING_UTF_8)}" + + private fun doRequest(requestUrl: String): String? { + val request = Request.Builder().url(requestUrl).header(HEADER_USER_AGENT, MainApp.getUserAgent()).build() + + try { + val response = client.newCall(request).execute() + if (response.code == HTTP_OK) { + return response.body.string() + } + } catch (_: Exception) { + } + + return null + } + + /** + * Reverse geocode specified location - get human readable name suitable for displaying from given coordinates. + * + * @param latitude GPS latitude + * @param longitude GPS longitude + * @param zoom level of detail to request + */ + fun reverseGeocode( + latitude: Double, + longitude: Double, + zoom: ZoomLevel = ZoomLevel.TOWN_BOROUGH + ): ReverseGeocodingResult? { + val response = doRequest("$reverseUrl&addressdetails=0&zoom=${zoom.int}&lat=$latitude&lon=$longitude") + return response?.let { gson.fromJson(it, ReverseGeocodingResult::class.java) } + } + + companion object { + private const val ENCODING_UTF_8 = "UTF-8" + private const val HEADER_USER_AGENT = "User-Agent" + + @Suppress("MagicNumber") + enum class ZoomLevel(val int: Int) { + COUNTRY(3), + STATE(5), + COUNTY(8), + CITY(10), + TOWN_BOROUGH(12), + VILLAGE_SUBURB(13), + NEIGHBOURHOOD(14), + LOCALITY(15), + MAJOR_STREETS(16), + MINOR_STREETS(17), + BUILDING(18), + MAX(19) + } + + data class ReverseGeocodingResult( + @SerializedName("lat") + val latitude: Double, + @SerializedName("lon") + val longitude: Double, + val name: String, + @SerializedName("display_name") + val displayName: String + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/account/AnonymousUser.kt b/app/src/main/java/com/nextcloud/client/account/AnonymousUser.kt new file mode 100644 index 000000000000..64e8feef41f6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/account/AnonymousUser.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account + +import android.accounts.Account +import android.content.Context +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudBasicCredentials +import java.net.URI + +/** + * This object represents anonymous user, ie. user that did not log in the Nextcloud server. + * It serves as a semantically correct "empty value", allowing simplification of logic + * in various components requiring user data, such as DB queries. + */ +internal data class AnonymousUser(private val accountType: String) : + User, + Parcelable { + + companion object { + @JvmStatic + fun fromContext(context: Context): AnonymousUser { + val type = context.getString(R.string.account_type) + return AnonymousUser(type) + } + + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): AnonymousUser = AnonymousUser(source) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + private constructor(source: Parcel) : this( + source.readString() as String + ) + + override val accountName: String = "anonymous@nohost" + override val server = Server(URI.create(""), MainApp.MINIMUM_SUPPORTED_SERVER_VERSION) + override val isAnonymous = true + + @Deprecated( + "Temporary workaround: Legacy Android Account access. Refactor code to use User object " + + "directly instead of platform Account." + ) + override fun toPlatformAccount(): Account = Account(accountName, accountType) + + @Deprecated( + "Temporary workaround: Legacy OwnCloudAccount access. Refactor code to use User object " + + "directly instead of OwnCloudAccount." + ) + override fun toOwnCloudAccount(): OwnCloudAccount = OwnCloudAccount(Uri.EMPTY, OwnCloudBasicCredentials("", "")) + + override fun nameEquals(user: User?): Boolean = user?.accountName.equals(accountName, true) + + override fun nameEquals(accountName: CharSequence?): Boolean = + accountName?.toString().equals(this.accountType, true) + + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) { + writeString(accountType) + } +} diff --git a/app/src/main/java/com/nextcloud/client/account/CurrentAccountProvider.java b/app/src/main/java/com/nextcloud/client/account/CurrentAccountProvider.java new file mode 100644 index 000000000000..b74bb3f0add0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/account/CurrentAccountProvider.java @@ -0,0 +1,38 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account; + +import android.accounts.Account; + +import androidx.annotation.NonNull; + +/** + * This interface provides access to currently selected user. + * + * @see UserAccountManager + */ +public interface CurrentAccountProvider { + /** + * Get currently active account. + * Replaced by getUser() + * + * @return Currently selected {@link Account} or first valid {@link Account} registered in OS or null, if not available at all. + */ + @Deprecated + @NonNull + Account getCurrentAccount(); + + /** + * Get currently active user profile. If there is no active user, anonymous user is returned. + * + * @return User profile. Profile is never null. + */ + @NonNull + default User getUser() { + return new AnonymousUser("dummy"); + } +} diff --git a/app/src/main/java/com/nextcloud/client/account/MockUser.kt b/app/src/main/java/com/nextcloud/client/account/MockUser.kt new file mode 100644 index 000000000000..abf90b58875a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/account/MockUser.kt @@ -0,0 +1,70 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account + +import android.accounts.Account +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import com.owncloud.android.MainApp +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudBasicCredentials +import java.net.URI + +/** + * This is a mock user object suitable for integration tests. Mocks obtained from code generators + * such as Mockito or MockK cannot be transported in Intent extras. + */ +data class MockUser(override val accountName: String, val accountType: String) : + User, + Parcelable { + + constructor() : this(DEFAULT_MOCK_ACCOUNT_NAME, DEFAULT_MOCK_ACCOUNT_TYPE) + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): MockUser = MockUser(source) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + const val DEFAULT_MOCK_ACCOUNT_NAME = "mock_account_name" + const val DEFAULT_MOCK_ACCOUNT_TYPE = "mock_account_type" + } + + private constructor(source: Parcel) : this( + source.readString() as String, + source.readString() as String + ) + + override val server = Server(URI.create(""), MainApp.MINIMUM_SUPPORTED_SERVER_VERSION) + override val isAnonymous = false + + @Deprecated( + "Temporary workaround: Legacy Android Account access. Refactor code to use User object " + + "directly instead of platform Account." + ) + override fun toPlatformAccount(): Account = Account(accountName, accountType) + + @Deprecated( + "Temporary workaround: Legacy OwnCloudAccount access. Refactor code to use User object " + + "directly instead of OwnCloudAccount." + ) + override fun toOwnCloudAccount(): OwnCloudAccount = OwnCloudAccount(Uri.EMPTY, OwnCloudBasicCredentials("", "")) + + override fun nameEquals(user: User?): Boolean = user?.accountName.equals(accountName, true) + + override fun nameEquals(accountName: CharSequence?): Boolean = + accountName?.toString().equals(this.accountType, true) + + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) { + writeString(accountName) + writeString(accountType) + } +} diff --git a/app/src/main/java/com/nextcloud/client/account/RegisteredUser.kt b/app/src/main/java/com/nextcloud/client/account/RegisteredUser.kt new file mode 100644 index 000000000000..cce88ac0c6bc --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/account/RegisteredUser.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account + +import android.accounts.Account +import android.os.Parcel +import android.os.Parcelable +import com.owncloud.android.lib.common.OwnCloudAccount + +/** + * This class represents normal user logged into the Nextcloud server. + */ +internal data class RegisteredUser( + private val account: Account, + private val ownCloudAccount: OwnCloudAccount, + override val server: Server +) : User { + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): RegisteredUser = RegisteredUser(source) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + private constructor(source: Parcel) : this( + source.readParcelable(Account::class.java.classLoader) as Account, + source.readParcelable(OwnCloudAccount::class.java.classLoader) as OwnCloudAccount, + source.readParcelable(Server::class.java.classLoader) as Server + ) + + override val isAnonymous = false + + override val accountName: String get() { + return account.name + } + + @Deprecated( + "Temporary workaround: Legacy Android Account access. Refactor code to use User object " + + "directly instead of platform Account." + ) + override fun toPlatformAccount(): Account = account + + @Deprecated( + "Temporary workaround: Legacy OwnCloudAccount access. Refactor code to use User object " + + "directly instead of OwnCloudAccount." + ) + override fun toOwnCloudAccount(): OwnCloudAccount = ownCloudAccount + + override fun nameEquals(user: User?): Boolean = nameEquals(user?.accountName) + + override fun nameEquals(accountName: CharSequence?): Boolean = + accountName?.toString().equals(this.accountName, true) + + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) { + writeParcelable(account, 0) + writeParcelable(ownCloudAccount, 0) + writeParcelable(server, 0) + } +} diff --git a/app/src/main/java/com/nextcloud/client/account/Server.kt b/app/src/main/java/com/nextcloud/client/account/Server.kt new file mode 100644 index 000000000000..63f4dfb86187 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/account/Server.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account + +import android.os.Parcel +import android.os.Parcelable +import com.owncloud.android.lib.resources.status.OwnCloudVersion +import java.net.URI + +/** + * This object provides all information necessary to interact + * with backend server. + */ +data class Server(val uri: URI, val version: OwnCloudVersion) : Parcelable { + + constructor(source: Parcel) : this( + source.readSerializable() as URI, + source.readParcelable(OwnCloudVersion::class.java.classLoader) as OwnCloudVersion + ) + + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) { + writeSerializable(uri) + writeParcelable(version, 0) + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): Server = Server(source) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/account/User.kt b/app/src/main/java/com/nextcloud/client/account/User.kt new file mode 100644 index 000000000000..1a7da61d61f7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/account/User.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account + +import android.accounts.Account +import android.os.Parcelable +import com.owncloud.android.lib.common.OwnCloudAccount + +interface User : + Parcelable, + com.nextcloud.common.User { + override val accountName: String + val server: Server + val isAnonymous: Boolean + + /** + * This is temporary helper method created to facilitate incremental refactoring. + * Code using legacy platform Account can be partially converted to instantiate User + * object and use account instance when required. + * + * This method calls will allow tracing code awaiting further refactoring. + * + * @return Account instance that is associated with this User object. + */ + @Deprecated( + "Temporary workaround: Legacy Android Account access. Refactor code to use User object " + + "directly instead of platform Account." + ) + override fun toPlatformAccount(): Account + + /** + * This is temporary helper method created to facilitate incremental refactoring. + * Code using legacy ownCloud account can be partially converted to instantiate User + * object and use account instance when required. + * + * This method calls will allow tracing code awaiting further refactoring. + * + * @return OwnCloudAccount instance that is associated with this User object. + */ + @Deprecated( + "Temporary workaround: Legacy OwnCloudAccount access. Refactor code to use User object " + + "directly instead of OwnCloudAccount." + ) + fun toOwnCloudAccount(): OwnCloudAccount + + /** + * Compare account names, case insensitive. + * + * @return true if account names are same, false otherwise + */ + fun nameEquals(user: User?): Boolean + + /** + * Compare account names, case insensitive. + * + * @return true if account names are same, false otherwise + */ + fun nameEquals(accountName: CharSequence?): Boolean +} diff --git a/app/src/main/java/com/nextcloud/client/account/UserAccountManager.java b/app/src/main/java/com/nextcloud/client/account/UserAccountManager.java new file mode 100644 index 000000000000..3317e180192d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/account/UserAccountManager.java @@ -0,0 +1,159 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.app.Activity; +import android.content.Intent; + +import com.owncloud.android.MainApp; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.resources.status.OwnCloudVersion; + +import java.util.List; +import java.util.Optional; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public interface UserAccountManager extends CurrentAccountProvider { + + int ACCOUNT_VERSION = 1; + int ACCOUNT_VERSION_WITH_PROPER_ID = 2; + String ACCOUNT_USES_STANDARD_PASSWORD = "ACCOUNT_USES_STANDARD_PASSWORD"; + String PENDING_FOR_REMOVAL = "PENDING_FOR_REMOVAL"; + + @Nullable + OwnCloudAccount getCurrentOwnCloudAccount(); + + /** + * Remove all NextCloud accounts from OS account manager. + */ + void removeAllAccounts(); + + /** + * Remove registered user. + * + * @param user user to remove + * @return true if account was removed successfully, false otherwise + */ + boolean removeUser(User user); + + /** + * Get configured NextCloud's user accounts. + * + * @return Array of accounts or empty array, if accounts are not configured. + */ + @NonNull + Account[] getAccounts(); + + /** + * Get configured nextcloud user accounts + * @return List of users or empty list, if users are not registered. + */ + @NonNull + List getAllUsers(); + + /** + * Get user with a specific account name. + * + * @param accountName Account name of the requested user + * @return User or empty optional if user does not exist. + */ + @NonNull + Optional getUser(CharSequence accountName); + + + User getAnonymousUser(); + + /** + * Check if Nextcloud account is registered in {@link android.accounts.AccountManager} + * + * @param account Account to check for + * @return true if account is registered, false otherwise + */ + boolean exists(Account account); + + /** + * Verifies that every account has userId set and sets the user id if not. + * This migration is idempotent and can be run multiple times until + * all accounts are migrated. + * + * @return true if migration was successful, false if any account failed to be migrated + */ + boolean migrateUserId(); + + @Nullable + Account getAccountByName(String name); + + boolean setCurrentOwnCloudAccount(String accountName); + + boolean setCurrentOwnCloudAccount(User user); + + /** + * Access the version of the OC server corresponding to an account SAVED IN THE ACCOUNTMANAGER + * + * @param account ownCloud account + * @return Version of the OC server corresponding to account, according to the data saved + * in the system AccountManager + */ + @Deprecated + @NonNull + OwnCloudVersion getServerVersion(Account account); + + void resetOwnCloudAccount(); + + /** + * Checks if an account owns the file (file's ownerId is the same as account name) + * + * @param file File to check + * @param account account to compare + * @return false if ownerId is not set or owner is a different account + */ + @Deprecated + boolean accountOwnsFile(OCFile file, Account account); + + /** + * Checks if an account owns the file (file's ownerId is the same as account name) + * + * @param file File to check + * @param user user to check against + * @return false if ownerId is not set or owner is a different account + */ + boolean userOwnsFile(OCFile file, User user); + + /** + * Extract username from account. + *

+ * Full account name is in form of "username@nextcloud.domain". + * + * @param user user instance + * @return User name (without domain) or null, if name cannot be extracted. + */ + static String getUsername(User user) { + final String name = user.getAccountName(); + return name.substring(0, name.lastIndexOf('@')); + } + + @Nullable + static String getDisplayName(User user) { + return AccountManager.get(MainApp.getAppContext()).getUserData(user.toPlatformAccount(), + AccountUtils.Constants.KEY_DISPLAY_NAME); + } + + /** + * Launch account registration activity. + *

+ * This method returns immediately. Authenticator activity will be launched asynchronously. + * + * @param activity Activity used to launch authenticator flow via {@link Activity#startActivity(Intent)} + */ + void startAccountCreation(Activity activity); +} diff --git a/app/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java b/app/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java new file mode 100644 index 000000000000..3aec76281df3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java @@ -0,0 +1,465 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023-2024 TSI-mc + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.account; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerFuture; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.text.TextUtils; + +import com.nextcloud.client.onboarding.FirstRunActivity; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.utils.extensions.AccountExtensionsKt; +import com.nmc.android.ui.LauncherActivity; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.authentication.AuthenticatorActivity; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.UserInfo; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.status.OwnCloudVersion; +import com.owncloud.android.lib.resources.users.GetUserInfoRemoteOperation; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class UserAccountManagerImpl implements UserAccountManager { + + private static final String TAG = UserAccountManagerImpl.class.getSimpleName(); + private static final String PREF_SELECT_OC_ACCOUNT = "select_oc_account"; + + private Context context; + private final AccountManager accountManager; + + public static UserAccountManagerImpl fromContext(Context context) { + AccountManager am = (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE); + return new UserAccountManagerImpl(context, am); + } + + @Inject + public UserAccountManagerImpl( + Context context, + AccountManager accountManager + ) { + this.context = context; + this.accountManager = accountManager; + } + + @Override + public void removeAllAccounts() { + for (Account account : getAccounts()) { + accountManager.removeAccount(account, null, null); + } + } + + @Override + public boolean removeUser(User user) { + try { + AccountManagerFuture result = accountManager.removeAccount(user.toPlatformAccount(), + null, + null); + return result.getResult(); + } catch (OperationCanceledException| AuthenticatorException| IOException ex) { + return false; + } + } + + @Override + @NonNull + public Account[] getAccounts() { + return accountManager.getAccountsByType(getAccountType()); + } + + @Override + @NonNull + public List getAllUsers() { + Account[] accounts = getAccounts(); + List users = new ArrayList<>(accounts.length); + for (Account account : accounts) { + User user = createUserFromAccount(account); + if (user != null) { + users.add(user); + } + } + return users; + } + + @Override + public boolean exists(Account account) { + try { + if (account == null) { + Log_OC.d(TAG, "account is null"); + return false; + } + + Account[] nextcloudAccounts = getAccounts(); + if (nextcloudAccounts.length == 0) { + Log_OC.d(TAG, "nextcloudAccounts are empty"); + return false; + } + + if (account.name.isEmpty()) { + Log_OC.d(TAG, "account name is empty"); + return false; + } + + int lastAtPos = account.name.lastIndexOf('@'); + if (lastAtPos == -1) { + Log_OC.d(TAG, "lastAtPos cannot be found"); + return false; + } + + boolean isLastAtPosInBoundsForHostAndPort = lastAtPos + 1 < account.name.length(); + if (!isLastAtPosInBoundsForHostAndPort) { + Log_OC.d(TAG, "lastAtPos not in bounds"); + return false; + } + + String hostAndPort = account.name.substring(lastAtPos + 1); + + String username = account.name.substring(0, lastAtPos); + if (hostAndPort.isEmpty() || username.isEmpty()) { + Log_OC.d(TAG, "hostAndPort or username is empty"); + return false; + } + + String otherHostAndPort; + String otherUsername; + + for (Account otherAccount : nextcloudAccounts) { + // Skip null accounts or accounts with null names + if (otherAccount == null || otherAccount.name.isEmpty()) { + continue; + } + + lastAtPos = otherAccount.name.lastIndexOf('@'); + + // Skip invalid account names + if (lastAtPos == -1) { + continue; + } + + boolean isLastAtPosInBoundsForOtherHostAndPort = lastAtPos + 1 < otherAccount.name.length(); + if (!isLastAtPosInBoundsForOtherHostAndPort) { + continue; + } + otherHostAndPort = otherAccount.name.substring(lastAtPos + 1); + + otherUsername = otherAccount.name.substring(0, lastAtPos); + + if (otherHostAndPort.equals(hostAndPort) && + otherUsername.equalsIgnoreCase(username)) { + return true; + } + } + + return false; + } catch (Exception e) { + Log_OC.d(TAG, "Exception caught at UserAccountManagerImpl.exists(): " + e); + return false; + } + } + + @Override + @NonNull + public Account getCurrentAccount() { + Account[] ocAccounts = getAccounts(); + + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); + SharedPreferences appPreferences = PreferenceManager.getDefaultSharedPreferences(context); + String accountName = appPreferences.getString(PREF_SELECT_OC_ACCOUNT, null); + + Account defaultAccount = Arrays.stream(ocAccounts) + .filter(account -> account.name.equals(accountName)) + .findFirst() + .orElse(null); + + // take first which is not pending for removal account as fallback + if (defaultAccount == null) { + defaultAccount = Arrays.stream(ocAccounts) + .filter(account -> !arbitraryDataProvider.getBooleanValue(account.name, PENDING_FOR_REMOVAL)) + .findFirst() + .orElse(null); + } + + if (defaultAccount == null) { + if (ocAccounts.length > 0) { + defaultAccount = ocAccounts[0]; + } else { + defaultAccount = getAnonymousAccount(); + } + } + + return defaultAccount; + } + + private Account getAnonymousAccount() { + return new Account("Anonymous", context.getString(R.string.anonymous_account_type)); + } + + /** + * Temporary solution to convert platform account to user instance. + * It takes null and returns null on error to ease error handling + * in legacy code. + * + * @param account Account instance + * @return User instance or null, if conversion failed + */ + @Nullable + private User createUserFromAccount(@NonNull Account account) { + Context safeContext = context != null ? context : MainApp.getAppContext(); + if (safeContext == null) { + Log_OC.e(TAG, "Unable to obtain a valid context"); + return null; + } + + if (AccountExtensionsKt.isAnonymous(account, safeContext)) { + return null; + } + + OwnCloudAccount ownCloudAccount; + try { + ownCloudAccount = new OwnCloudAccount(account, safeContext); + } catch (Exception ex) { + return null; + } + + /* + * Server version + */ + String serverVersionStr = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_VERSION); + OwnCloudVersion serverVersion; + if (serverVersionStr != null) { + serverVersion = new OwnCloudVersion(serverVersionStr); + } else { + serverVersion = MainApp.MINIMUM_SUPPORTED_SERVER_VERSION; + } + + /* + * Server address + */ + String serverAddressStr = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL); + if (serverAddressStr == null || serverAddressStr.isEmpty()) { + return AnonymousUser.fromContext(safeContext); + } + URI serverUri = URI.create(serverAddressStr); // TODO: validate + + return new RegisteredUser( + account, + ownCloudAccount, + new Server(serverUri, serverVersion) + ); + } + + /** + * Get user. If user cannot be retrieved due to data error, anonymous user is returned instead. + * + * + * @return User instance + */ + @NonNull + @Override + public User getUser() { + Account account = getCurrentAccount(); + User user = createUserFromAccount(account); + return user != null ? user : AnonymousUser.fromContext(context); + } + + @Override + @NonNull + public Optional getUser(CharSequence accountName) { + Account account = getAccountByName(accountName.toString()); + User user = createUserFromAccount(account); + return Optional.ofNullable(user); + } + + @Override + public User getAnonymousUser() { + return AnonymousUser.fromContext(context); + } + + @Override + @Nullable + public OwnCloudAccount getCurrentOwnCloudAccount() { + try { + Account currentPlatformAccount = getCurrentAccount(); + return new OwnCloudAccount(currentPlatformAccount, context); + } catch (AccountUtils.AccountNotFoundException | IllegalArgumentException ex) { + return null; + } + } + + @Override + @NonNull + public Account getAccountByName(String name) { + for (Account account : getAccounts()) { + if (account.name.equals(name)) { + return account; + } + } + + return getAnonymousAccount(); + } + + @Override + public boolean setCurrentOwnCloudAccount(String accountName) { + boolean result = false; + if (accountName != null) { + for (final Account account : getAccounts()) { + if (accountName.equals(account.name)) { + SharedPreferences.Editor appPrefs = PreferenceManager.getDefaultSharedPreferences(context).edit(); + appPrefs.putString(PREF_SELECT_OC_ACCOUNT, accountName); + appPrefs.apply(); + result = true; + break; + } + } + } + return result; + } + + @Override + public boolean setCurrentOwnCloudAccount(User chosenUser) { + boolean result = false; + if (chosenUser != null) { + for (final User user : getAllUsers()) { + if (chosenUser.nameEquals(user)) { + SharedPreferences.Editor appPrefs = PreferenceManager.getDefaultSharedPreferences(context).edit(); + appPrefs.putString(PREF_SELECT_OC_ACCOUNT, user.getAccountName()); + appPrefs.apply(); + result = true; + break; + } + } + } + return result; + } + + @Deprecated + @Override + @NonNull + public OwnCloudVersion getServerVersion(Account account) { + OwnCloudVersion serverVersion = MainApp.MINIMUM_SUPPORTED_SERVER_VERSION; + + if (account != null) { + AccountManager accountMgr = AccountManager.get(MainApp.getAppContext()); + String serverVersionStr = accountMgr.getUserData(account, com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_VERSION); + + if (serverVersionStr != null) { + serverVersion = new OwnCloudVersion(serverVersionStr); + } + } + + return serverVersion; + } + + @Override + public void resetOwnCloudAccount() { + SharedPreferences.Editor appPrefs = PreferenceManager.getDefaultSharedPreferences(context).edit(); + appPrefs.putString(PREF_SELECT_OC_ACCOUNT, null); + appPrefs.apply(); + } + + @Override + public boolean accountOwnsFile(OCFile file, Account account) { + final String ownerId = file.getOwnerId(); + return TextUtils.isEmpty(ownerId) || account.name.split("@")[0].equalsIgnoreCase(ownerId); + } + + @Override + public boolean userOwnsFile(OCFile file, User user) { + return accountOwnsFile(file, user.toPlatformAccount()); + } + + public boolean migrateUserId() { + Account[] ocAccounts = accountManager.getAccountsByType(MainApp.getAccountType(context)); + String userId; + String displayName; + GetUserInfoRemoteOperation remoteUserNameOperation = new GetUserInfoRemoteOperation(); + int failed = 0; + for (Account account : ocAccounts) { + String storedUserId = accountManager.getUserData(account, com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID); + + if (!TextUtils.isEmpty(storedUserId)) { + continue; + } + + // add userId + try { + OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); + NextcloudClient nextcloudClient = OwnCloudClientManagerFactory + .getDefaultSingleton() + .getNextcloudClientFor(ocAccount, context); + + RemoteOperationResult result = remoteUserNameOperation.execute(nextcloudClient); + + if (result.isSuccess()) { + UserInfo userInfo = result.getResultData(); + userId = userInfo.getId(); + displayName = userInfo.getDisplayName(); + } else { + // skip account, try it next time + Log_OC.e(TAG, "Error while getting username for account: " + account.name); + failed++; + continue; + } + } catch (Exception e) { + Log_OC.e(TAG, "Error while getting username: " + e.getMessage()); + failed++; + continue; + } + + accountManager.setUserData(account, + com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_DISPLAY_NAME, + displayName); + accountManager.setUserData(account, + com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID, + userId); + } + + return failed == 0; + } + + private String getAccountType() { + return context.getString(R.string.account_type); + } + + @Override + public void startAccountCreation(final Activity activity) { + + // skipping AuthenticatorActivity redirection when user is on Launcher or FirstRun Activity + if (activity instanceof LauncherActivity || activity instanceof FirstRunActivity) return; + + Intent intent = new Intent(context, AuthenticatorActivity.class); + + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } +} diff --git a/app/src/main/java/com/nextcloud/client/appinfo/AppInfo.kt b/app/src/main/java/com/nextcloud/client/appinfo/AppInfo.kt new file mode 100644 index 000000000000..acc55a627318 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/appinfo/AppInfo.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.appinfo + +import android.content.Context + +/** + * This class provides general, static information about application + * build. + * + * All methods should be thread-safe. + */ +interface AppInfo { + val versionName: String + val versionCode: Int + val isDebugBuild: Boolean + fun getAppVersion(context: Context): String +} diff --git a/app/src/main/java/com/nextcloud/client/appinfo/AppInfoImpl.kt b/app/src/main/java/com/nextcloud/client/appinfo/AppInfoImpl.kt new file mode 100644 index 000000000000..9868e0a5187f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/appinfo/AppInfoImpl.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.appinfo + +import android.content.Context +import android.content.pm.PackageManager +import com.owncloud.android.BuildConfig +import com.owncloud.android.lib.common.utils.Log_OC + +class AppInfoImpl : AppInfo { + override val versionName: String = BuildConfig.VERSION_NAME + override val versionCode: Int = BuildConfig.VERSION_CODE + override val isDebugBuild: Boolean = BuildConfig.DEBUG + + override fun getAppVersion(context: Context): String = try { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + packageInfo.versionName ?: "n/a" + } catch (e: PackageManager.NameNotFoundException) { + Log_OC.e(this, "Trying to get packageName", e.cause) + "n/a" + } +} diff --git a/app/src/main/java/com/nextcloud/client/appinfo/AppInfoModule.kt b/app/src/main/java/com/nextcloud/client/appinfo/AppInfoModule.kt new file mode 100644 index 000000000000..5e32efbb3872 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/appinfo/AppInfoModule.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.appinfo + +import dagger.Module +import dagger.Provides + +@Module +class AppInfoModule { + @Provides + fun appInfo(): AppInfo = AppInfoImpl() +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt new file mode 100644 index 000000000000..2223fe2692e7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -0,0 +1,582 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.assistant + +import android.app.Activity +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.client.assistant.chat.ChatContent +import com.nextcloud.client.assistant.chat.ChatViewModel +import com.nextcloud.client.assistant.conversation.ConversationScreen +import com.nextcloud.client.assistant.conversation.ConversationViewModel +import com.nextcloud.client.assistant.conversation.repository.MockConversationRemoteRepository +import com.nextcloud.client.assistant.extensions.getInputTitle +import com.nextcloud.client.assistant.model.AssistantPage +import com.nextcloud.client.assistant.model.AssistantScreenState +import com.nextcloud.client.assistant.model.ScreenOverlayState +import com.nextcloud.client.assistant.repository.local.MockAssistantLocalRepository +import com.nextcloud.client.assistant.repository.remote.MockAssistantRemoteRepository +import com.nextcloud.client.assistant.task.TaskView +import com.nextcloud.client.assistant.taskTypes.TaskTypesRow +import com.nextcloud.client.assistant.translate.TranslationScreen +import com.nextcloud.client.assistant.translate.TranslationViewModel +import com.nextcloud.ui.composeActivity.ComposeActivity +import com.nextcloud.ui.composeActivity.ComposeViewModel +import com.nextcloud.ui.composeComponents.alertDialog.SimpleAlertDialog +import com.nextcloud.ui.composeComponents.alertDialog.TaskSelectionAlertDialog +import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet +import com.nextcloud.utils.extensions.getChat +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.status.OCCapability +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val CHAT_INPUT_DELAY = 100L +private const val PULL_TO_REFRESH_DELAY = 1500L + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AssistantScreen( + composeViewModel: ComposeViewModel, + viewModel: AssistantViewModel, + chatViewModel: ChatViewModel, + conversationViewModel: ConversationViewModel, + capability: OCCapability, + activity: Activity +) { + val selectedText by composeViewModel.selectedText.collectAsState() + val sessionTitle by chatViewModel.sessionTitle.collectAsState() + val sessionId by viewModel.sessionId.collectAsState() + val messageId by viewModel.snackbarMessageId.collectAsState() + val screenOverlayState by viewModel.screenOverlayState.collectAsState() + val selectedTaskType by viewModel.selectedTaskType.collectAsState() + val isTranslationTask by viewModel.isTranslationTask.collectAsState() + val filteredTaskList by viewModel.filteredTaskList.collectAsState() + val screenState by viewModel.screenState.collectAsState() + val taskTypes by viewModel.taskTypes.collectAsState() + val scope = rememberCoroutineScope() + val pullRefreshState = rememberPullToRefreshState() + val snackbarHostState = remember { SnackbarHostState() } + val pagerState = + rememberPagerState(initialPage = AssistantPage.Content.id, pageCount = { AssistantPage.entries.size }) + + LaunchedEffect(messageId) { + messageId?.let { + snackbarHostState.showSnackbar(activity.getString(it)) + viewModel.updateSnackbarMessage(null) + } + } + + LaunchedEffect(selectedText) { + selectedText?.let { copiedText -> + if (copiedText.isBlank()) { + return@LaunchedEffect + } + + if (pagerState.currentPage == AssistantPage.Conversation.id) { + pagerState.scrollToPage(AssistantPage.Content.id) + } + + scope.launch(Dispatchers.IO) { + val types = viewModel.getRemoteRepository().fetchTaskTypes() + if (!types.isNullOrEmpty()) { + withContext(Dispatchers.Main) { + viewModel.updateScreenOverlayState(ScreenOverlayState.TaskTypes(copiedText, types)) + snackbarHostState.showSnackbar(activity.getString(R.string.assistant_screen_text_selected)) + } + } + } + } + } + + LaunchedEffect(Unit) { + viewModel.startPolling(sessionId) + } + + DisposableEffect(Unit) { + onDispose { + viewModel.stopPolling() + } + } + + HorizontalPager( + state = pagerState, + userScrollEnabled = taskTypes.getChat() != null + ) { page -> + when (page) { + AssistantPage.Conversation.id -> { + ConversationScreen(viewModel = conversationViewModel, close = { + scope.launch { + pagerState.scrollToPage(AssistantPage.Content.id) + } + }, openChat = { conversation -> + viewModel.updateInputBarText("") + chatViewModel.updateSessionTitle(conversation.timestamp) + chatViewModel.selectConversation(conversation.id) + taskTypes.getChat()?.let { chatTaskType -> + viewModel.selectTaskType(chatTaskType) + } + scope.launch { + pagerState.scrollToPage(AssistantPage.Content.id) + } + }) + } + + AssistantPage.Content.id -> { + Scaffold( + modifier = Modifier.pullToRefresh( + false, + pullRefreshState, + onRefresh = { + scope.launch { + delay(PULL_TO_REFRESH_DELAY) + + val currentSessionId = sessionId + if (currentSessionId != null) { + chatViewModel.selectConversation(currentSessionId) + } else { + viewModel.fetchTaskList() + } + } + } + ), + topBar = { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + taskTypes?.let { + TaskTypesRow(selectedTaskType, data = it, selectTaskType = { task -> + viewModel.selectTaskType(task) + }, navigateToConversationList = { + scope.launch { + pagerState.scrollToPage(AssistantPage.Conversation.id) + } + }) + } + + if (selectedTaskType?.isChat() == true && sessionTitle != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = sessionTitle!!, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + } + } + } + }, + bottomBar = { + if (!taskTypes.isNullOrEmpty() && selectedTaskType?.isTranslate() != true) { + InputBar( + sessionId, + selectedTaskType, + viewModel, + chatViewModel + ) + } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + }, + floatingActionButton = { + if (selectedTaskType?.isTranslate() == true && !isTranslationTask) { + FloatingActionButton(onClick = { + viewModel.updateTranslationTaskState(true) + viewModel.updateScreenState(AssistantScreenState.Translation(null)) + }, content = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_plus), + contentDescription = "translate button" + ) + } + }) + } + } + ) { paddingValues -> + when (screenState) { + is AssistantScreenState.EmptyContent -> { + val state = (screenState as AssistantScreenState.EmptyContent) + EmptyContent( + paddingValues, + iconId = state.iconId, + descriptionId = state.descriptionId, + titleId = state.titleId + ) + } + + AssistantScreenState.TaskContent -> { + TaskContent( + paddingValues, + filteredTaskList ?: listOf(), + viewModel, + capability + ) + } + + AssistantScreenState.ChatContent -> { + ChatContent( + chatViewModel = chatViewModel, + modifier = Modifier.padding(paddingValues) + ) + } + + is AssistantScreenState.Translation -> { + selectedTaskType?.let { + val task = (screenState as AssistantScreenState.Translation).task + val textToTranslate = task?.input?.input ?: selectedText ?: "" + + val translationViewModel = + TranslationViewModel(remoteRepository = viewModel.getRemoteRepository()) + + translationViewModel.init(it, task, textToTranslate) + + TranslationScreen( + viewModel = translationViewModel, + assistantViewModel = viewModel + ) + } + } + + else -> EmptyContent( + paddingValues, + iconId = R.drawable.spinner_inner, + titleId = null, + descriptionId = R.string.common_loading + ) + } + + LinearProgressIndicator( + progress = { pullRefreshState.distanceFraction }, + modifier = Modifier.fillMaxWidth() + ) + + OverlayState(screenOverlayState, activity, viewModel) + } + } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun InputBar( + sessionId: Long?, + selectedTaskType: TaskTypeData?, + viewModel: AssistantViewModel, + chatViewModel: ChatViewModel +) { + val scope = rememberCoroutineScope() + val text by viewModel.inputBarText.collectAsState() + val chatUIState by chatViewModel.uiState.collectAsState() + + Surface( + tonalElevation = 3.dp, + shadowElevation = 4.dp, + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.assistant_output_generation_warning_text), + fontSize = 11.sp, + textAlign = TextAlign.Center, + color = colorResource(R.color.text_color) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = text, + onValueChange = { viewModel.updateInputBarText(it) }, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + placeholder = { Text(selectedTaskType?.description ?: "") }, + singleLine = true + ) + + IconButton( + onClick = { + if (text.isBlank()) { + return@IconButton + } + + val taskType = selectedTaskType ?: return@IconButton + if (taskType.isChat()) { + if (sessionId != null) { + chatViewModel.sendMessage(content = text, sessionId = sessionId) + } else { + chatViewModel.startNewConversation(content = text) + } + } else { + viewModel.createTask(input = text, taskType = taskType) + } + + scope.launch { + delay(CHAT_INPUT_DELAY) + viewModel.updateInputBarText("") + } + }, + enabled = chatUIState.canSend() + ) { + Icon( + painter = painterResource(id = R.drawable.ic_send), + contentDescription = stringResource(R.string.assistant_screen_send_message), + tint = if (chatUIState.canSend()) { + MaterialTheme.colorScheme.primary + } else { + colorResource( + R.color.disabled_text + ) + } + ) + } + } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun OverlayState(state: ScreenOverlayState?, activity: Activity, viewModel: AssistantViewModel) { + state?.let { + when (state) { + is ScreenOverlayState.DeleteTask -> { + SimpleAlertDialog( + title = stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_title), + description = stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_description), + onDismiss = { viewModel.updateScreenOverlayState(null) }, + onComplete = { viewModel.deleteTask(state.id) } + ) + } + + is ScreenOverlayState.TaskActions -> { + val actions = state.getActions(activity, onDeleteCompleted = { deleteTask -> + viewModel.updateScreenOverlayState(deleteTask) + }) + + MoreActionsBottomSheet( + title = state.task.getInputTitle(), + actions = actions, + onDismiss = { viewModel.updateScreenOverlayState(null) } + ) + } + + is ScreenOverlayState.TaskTypes -> { + TaskSelectionAlertDialog(state.taskTypes, onDismiss = { + viewModel.updateScreenOverlayState(null) + }, onConfirm = { + viewModel.selectTaskType(it) + viewModel.updateInputBarText(state.copiedText) + + if (it.isTranslate()) { + viewModel.updateTranslationTaskState(true) + viewModel.updateScreenState(AssistantScreenState.Translation(null)) + } + }) + } + } + } +} + +@Composable +private fun TaskContent( + paddingValues: PaddingValues, + taskList: List, + viewModel: AssistantViewModel, + capability: OCCapability +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(12.dp), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + items(taskList, key = { it.id }) { task -> + TaskView( + task, + viewModel, + capability, + showTaskActions = { + val newState = ScreenOverlayState.TaskActions(task) + viewModel.updateScreenOverlayState(newState) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun EmptyContent(paddingValues: PaddingValues, iconId: Int?, descriptionId: Int?, titleId: Int?) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + iconId?.let { + Image( + painter = painterResource(id = iconId), + modifier = Modifier.size(32.dp), + colorFilter = ColorFilter.tint(color = colorResource(R.color.text_color)), + contentDescription = null + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + titleId?.let { + Text( + text = stringResource(titleId), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + color = colorResource(R.color.text_color) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + descriptionId?.let { + Text( + text = stringResource(descriptionId), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = colorResource(R.color.text_color) + ) + } + } +} + +@Suppress("MagicNumber") +@Composable +@Preview +private fun AssistantScreenPreview() { + MaterialTheme( + content = { + AssistantScreen( + composeViewModel = ComposeViewModel(), + conversationViewModel = getMockConversationViewModel(), + viewModel = getMockAssistantViewModel(false), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), + activity = ComposeActivity(), + capability = OCCapability().apply { + versionMayor = 30 + } + ) + } + ) +} + +@Suppress("MagicNumber") +@Composable +@Preview +private fun AssistantEmptyScreenPreview() { + MaterialTheme( + content = { + AssistantScreen( + composeViewModel = ComposeViewModel(), + conversationViewModel = getMockConversationViewModel(), + viewModel = getMockAssistantViewModel(true), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), + activity = ComposeActivity(), + capability = OCCapability().apply { + versionMayor = 30 + } + ) + } + ) +} + +private fun getMockConversationViewModel(): ConversationViewModel { + val mockRemoteRepository = MockConversationRemoteRepository() + return ConversationViewModel( + remoteRepository = mockRemoteRepository + ) +} + +fun getMockAssistantViewModel(giveEmptyTasks: Boolean): AssistantViewModel { + val mockLocalRepository = MockAssistantLocalRepository() + val mockRemoteRepository = MockAssistantRemoteRepository(giveEmptyTasks) + return AssistantViewModel( + accountName = "test:localhost", + remoteRepository = mockRemoteRepository, + localRepository = mockLocalRepository, + sessionIdArg = null + ) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt new file mode 100644 index 000000000000..989bd26a1c97 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt @@ -0,0 +1,295 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.assistant + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.assistant.model.AssistantScreenState +import com.nextcloud.client.assistant.model.ScreenOverlayState +import com.nextcloud.client.assistant.repository.local.AssistantLocalRepository +import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository +import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +@Suppress("TooManyFunctions") +class AssistantViewModel( + private val accountName: String, + private val remoteRepository: AssistantRemoteRepository, + private val localRepository: AssistantLocalRepository, + sessionIdArg: Long? +) : ViewModel() { + + companion object { + private const val TAG = "AssistantViewModel" + private const val POLLING_INTERVAL_MS = 15_000L + } + + private val _inputBarText = MutableStateFlow("") + val inputBarText: StateFlow = _inputBarText + + private val _screenState = MutableStateFlow(null) + val screenState: StateFlow = _screenState + + private val _screenOverlayState = MutableStateFlow(null) + val screenOverlayState: StateFlow = _screenOverlayState + + private val _sessionId = MutableStateFlow(sessionIdArg) + val sessionId: StateFlow = _sessionId + + private val _snackbarMessageId = MutableStateFlow(null) + val snackbarMessageId: StateFlow = _snackbarMessageId + + private val _isTranslationTask = MutableStateFlow(false) + val isTranslationTask: StateFlow = _isTranslationTask + + private val selectedTask = MutableStateFlow(null) + + private val _selectedTaskType = MutableStateFlow(null) + val selectedTaskType: StateFlow = _selectedTaskType + + private val _taskTypes = MutableStateFlow?>(null) + val taskTypes: StateFlow?> = _taskTypes + + private var taskList: List? = null + + private val _filteredTaskList = MutableStateFlow?>(null) + val filteredTaskList: StateFlow?> = _filteredTaskList + + private var pollingJob: Job? = null + + init { + observeScreenState() + fetchTaskTypes() + } + + // region task polling + fun startPolling(sessionId: Long?) { + stopPolling() + + pollingJob = viewModelScope.launch(Dispatchers.IO) { + try { + while (isActive) { + delay(POLLING_INTERVAL_MS) + val taskType = _selectedTaskType.value ?: continue + if (!taskType.isChat()) { + Log_OC.d(TAG, "Polling task list") + pollTaskList() + } + } + } finally { + Log_OC.d(TAG, "Polling cancelled, sessionId: $sessionId") + } + } + } + + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + // endregion + + private suspend fun pollTaskList() { + val taskType = _selectedTaskType.value?.id ?: return + + val cachedTasks = localRepository.getCachedTasks(accountName, taskType) + if (cachedTasks.isNotEmpty()) { + _filteredTaskList.value = cachedTasks.sortedByDescending { it.id } + } + + val result = remoteRepository.getTaskList(taskType) + if (result != null) { + taskList = result + _filteredTaskList.value = taskList?.sortedByDescending { it.id } + localRepository.cacheTasks(result, accountName) + } + } + + private fun observeScreenState() { + viewModelScope.launch { + combine( + selectedTask, + _selectedTaskType, + _filteredTaskList + ) { selectedTask, selectedTaskType, tasks -> + val isChat = selectedTaskType?.isChat() == true + val isTranslation = + selectedTaskType?.isTranslate() == true && selectedTask?.isTranslate() == true + + when { + selectedTaskType == null -> AssistantScreenState.Loading + + isTranslation -> AssistantScreenState.Translation(selectedTask) + + isChat -> AssistantScreenState.ChatContent + + !isChat && tasks.isNullOrEmpty() -> AssistantScreenState.emptyTaskList() + + else -> { + if (!_isTranslationTask.value) { + AssistantScreenState.TaskContent + } else { + _screenState.value + } + } + } + }.collect { newState -> + _screenState.value = newState + } + } + } + + // region task + fun createTask(input: String, taskType: TaskTypeData) = viewModelScope.launch(Dispatchers.IO) { + val result = remoteRepository.createTask(input, taskType) + val message = if (result.isSuccess) { + R.string.assistant_screen_task_create_success_message + } else { + R.string.assistant_screen_task_create_fail_message + } + + updateSnackbarMessage(message) + delay(MILLIS_PER_SECOND * 2L) + fetchTaskList() + } + + fun selectTaskType(task: TaskTypeData) { + Log_OC.d(TAG, "Task type changed: ${task.name}, session id: ${_sessionId.value}") + + // clear task list immediately when task type change + if (_selectedTaskType.value != task) { + _filteredTaskList.update { + listOf() + } + } + + updateTaskType(task) + + if (!task.isChat()) { + fetchTaskList() + } + } + + private fun fetchTaskTypes() = viewModelScope.launch(Dispatchers.IO) { + val result = remoteRepository.fetchTaskTypes() + if (result.isNullOrEmpty()) { + _screenState.value = AssistantScreenState.emptyTaskTypes() + return@launch + } + + _taskTypes.update { + result + } + selectTaskType(result.first()) + } + + fun fetchTaskList() = viewModelScope.launch(Dispatchers.IO) { + val taskType = _selectedTaskType.value ?: return@launch + + val cached = localRepository.getCachedTasks(accountName, taskType.name) + if (cached.isNotEmpty()) { + _filteredTaskList.update { + cached.sortedByDescending { it.id } + } + } + + taskType.id?.let { typeId -> + remoteRepository.getTaskList(typeId)?.let { result -> + taskList = result + _filteredTaskList.value = result.sortedByDescending { it.id } + localRepository.cacheTasks(result, accountName) + updateSnackbarMessage(null) + } ?: updateSnackbarMessage(R.string.assistant_screen_task_list_error_state_message) + } + } + + fun deleteTask(id: Long) = viewModelScope.launch(Dispatchers.IO) { + val result = remoteRepository.deleteTask(id) + val message = if (result.isSuccess) { + R.string.assistant_screen_task_delete_success_message + } else { + R.string.assistant_screen_task_delete_fail_message + } + + updateSnackbarMessage(message) + + val taskType = _selectedTaskType.value ?: return@launch + if (result.isSuccess) { + removeTaskFromList(id) + localRepository.deleteTask(id, accountName, taskType.name) + } + } + // endregion + + private fun updateTaskType(value: TaskTypeData) { + _selectedTaskType.update { + value + } + } + + fun selectTask(task: Task?) { + selectedTask.update { + task + } + } + + fun updateSnackbarMessage(value: Int?) { + _snackbarMessageId.update { + value + } + } + + fun updateScreenOverlayState(value: ScreenOverlayState?) { + _screenOverlayState.update { + value + } + } + + fun updateInputBarText(value: String) { + _inputBarText.update { + value + } + } + + fun updateScreenState(state: AssistantScreenState) { + _screenState.update { + state + } + } + + fun updateTranslationTaskState(value: Boolean) { + _isTranslationTask.update { + value + } + } + + fun onTranslationScreenDismissed() { + updateInputBarText("") + updateTranslationTaskState(false) + selectTask(null) + } + + fun getRemoteRepository(): AssistantRemoteRepository = remoteRepository + + private fun removeTaskFromList(id: Long) { + _filteredTaskList.update { currentList -> + currentList?.filter { it.id != id } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt new file mode 100644 index 000000000000..7c5f11dd205b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt @@ -0,0 +1,459 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +@file:Suppress("TopLevelPropertyNaming", "MagicNumber") + +package com.nextcloud.client.assistant.chat + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.utils.TimeConstants +import com.nextcloud.utils.extensions.time +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage +import java.time.Instant + +private val MIN_CHAT_HEIGHT = 60.dp +private val CHAT_BUBBLE_CORNER_RADIUS = 8.dp +private val ASSISTANT_ICON_SIZE = 40.dp + +@Composable +fun ChatContent(chatViewModel: ChatViewModel, modifier: Modifier = Modifier) { + val uiState by chatViewModel.uiState.collectAsState() + val listState = rememberLazyListState() + + val messages = uiState.messages() + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.size - 1) + } + } + + when (uiState) { + is ChatUIState.Loading -> { + ChatLoadingContent(modifier) + } + + is ChatUIState.Empty -> { + ChatEmptyContent(modifier) + } + + is ChatUIState.Error -> { + val errorState = uiState as ChatUIState.Error + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState, + bottomSlot = { + ChatErrorBanner(errorState.errorType) + } + ) + } + + is ChatUIState.RetryAvailable -> { + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState, + bottomSlot = { + RetryButton(onClick = { chatViewModel.retryResponseGeneration() }) + } + ) + } + + is ChatUIState.Thinking -> { + ChatMessageList( + messages = messages, + showTypingIndicator = true, + modifier = modifier, + listState = listState + ) + } + + is ChatUIState.Content, + is ChatUIState.Sending -> { + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState + ) + } + } +} + +@Composable +private fun ChatMessageList( + messages: List, + showTypingIndicator: Boolean, + modifier: Modifier = Modifier, + listState: LazyListState, + bottomSlot: (@Composable () -> Unit)? = null +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.Bottom, + state = listState + ) { + items(messages, key = { it.id }) { message -> + if (message.role == "human") { + UserMessageItem(message) + } else { + AssistantMessageItem(message) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + if (showTypingIndicator) { + item { + AssistantTypingIndicator() + Spacer(modifier = Modifier.height(8.dp)) + } + } + + bottomSlot?.let { + item { it() } + } + } +} + +@Composable +private fun ChatLoadingContent(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ChatEmptyContent(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_assistant), + contentDescription = null, + tint = colorResource(R.color.secondary_text_color), + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.assistant_screen_empty_content_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + color = colorResource(R.color.text_color) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.assistant_screen_empty_content_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = colorResource(R.color.secondary_text_color) + ) + } + } +} + +@Composable +private fun ChatErrorBanner(errorType: ChatErrorType) { + val messageRes = when (errorType) { + ChatErrorType.LoadMessages -> R.string.assistant_screen_chat_fetch_error + ChatErrorType.SendMessage -> R.string.assistant_screen_chat_create_error + ChatErrorType.GenerateResponse -> R.string.assistant_screen_chat_generate_error + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp) + ) { + Text( + text = stringResource(messageRes), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun RetryButton(onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Button(onClick = onClick) { + Text(text = stringResource(R.string.assistant_screen_retry_response_generation)) + } + } +} + +@Composable +private fun AssistantTypingIndicator() { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.Bottom) { + AnimatedAssistantIcon() + Box( + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .defaultMinSize(minHeight = MIN_CHAT_HEIGHT) + .clip( + RoundedCornerShape( + topEnd = CHAT_BUBBLE_CORNER_RADIUS, + topStart = CHAT_BUBBLE_CORNER_RADIUS, + bottomEnd = CHAT_BUBBLE_CORNER_RADIUS + ) + ) + .background(color = colorResource(R.color.bg_message_bubble)) + ) { + TypingAnimation() + } + } + } +} + +@Composable +private fun AnimatedAssistantIcon() { + val infiniteTransition = rememberInfiniteTransition(label = "assistant_icon_animation") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween(TimeConstants.MILLIS_PER_SECOND, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "scale_animation" + ) + + Box( + modifier = Modifier + .size(ASSISTANT_ICON_SIZE) + .clip(CircleShape) + .background(color = colorResource(R.color.bg_message_bubble)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_assistant), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + modifier = Modifier.scale(scale) + ) + } +} + +@Composable +private fun TypingAnimation() { + val infiniteTransition = rememberInfiniteTransition(label = "typing_animation") + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(600, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "typing_alpha" + ) + + Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.Start) { + Text( + text = stringResource(R.string.assistant_thinking), + style = TextStyle( + color = colorResource(R.color.text_color).copy(alpha = alpha), + fontSize = 16.sp + ) + ) + } +} + +@Composable +private fun AssistantMessageItem(message: ChatMessage) { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.Bottom) { + Box( + modifier = Modifier + .size(ASSISTANT_ICON_SIZE) + .clip(CircleShape) + .background(color = colorResource(R.color.bg_message_bubble)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_assistant), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.Center + ) + } + Box( + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .defaultMinSize(minHeight = MIN_CHAT_HEIGHT) + .clip( + RoundedCornerShape( + topEnd = CHAT_BUBBLE_CORNER_RADIUS, + topStart = CHAT_BUBBLE_CORNER_RADIUS, + bottomEnd = CHAT_BUBBLE_CORNER_RADIUS + ) + ) + .background(color = colorResource(R.color.bg_message_bubble)) + ) { + MessageTextItem(message) + } + } + } +} + +@Composable +private fun UserMessageItem(message: ChatMessage) { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier + .padding(start = 16.dp, end = 8.dp) + .defaultMinSize(minHeight = MIN_CHAT_HEIGHT) + .clip( + RoundedCornerShape( + topEnd = CHAT_BUBBLE_CORNER_RADIUS, + topStart = CHAT_BUBBLE_CORNER_RADIUS, + bottomStart = CHAT_BUBBLE_CORNER_RADIUS + ) + ) + .background(color = colorResource(R.color.bg_message_bubble)) + ) { + MessageTextItem(message) + } + } +} + +@Composable +private fun MessageTextItem(message: ChatMessage) { + Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.Start) { + Text( + text = message.content, + style = TextStyle(color = colorResource(R.color.text_color), fontSize = 16.sp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = message.time(), + style = TextStyle(color = colorResource(R.color.secondary_text_color), fontSize = 12.sp), + modifier = Modifier.align(Alignment.End) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MessageTextItemPreview() { + val mockMessages = listOf( + ChatMessage( + id = 1, + sessionId = 101, + role = "human", + content = "Hey, how are you?", + timestamp = Instant.now().epochSecond, + ocpTaskId = null, + sources = "", + attachments = emptyList() + ), + ChatMessage( + id = 2, + sessionId = 101, + role = "assistant", + content = "I'm good! Here's a message from yesterday.", + timestamp = Instant.now().minusSeconds(86_400).epochSecond, + ocpTaskId = null, + sources = "", + attachments = emptyList() + ) + ) + + Column(modifier = Modifier.padding(16.dp)) { + mockMessages.forEach { message -> + MessageTextItem(message = message) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt new file mode 100644 index 000000000000..9bab64242fc8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +sealed class ChatErrorType { + data object LoadMessages : ChatErrorType() + data object SendMessage : ChatErrorType() + data object GenerateResponse : ChatErrorType() +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt new file mode 100644 index 000000000000..b2cefa8031f7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage + +sealed class ChatUIState { + data object Loading : ChatUIState() + data object Empty : ChatUIState() + data class Content(val messages: List) : ChatUIState() + data class Sending(val messages: List) : ChatUIState() + data class Thinking(val messages: List) : ChatUIState() + data class RetryAvailable(val messages: List) : ChatUIState() + data class Error(val messages: List, val errorType: ChatErrorType) : ChatUIState() + + fun messages(): List = when (this) { + is Content -> messages + is Sending -> messages + is Thinking -> messages + is RetryAvailable -> messages + is Error -> messages + is Loading, is Empty -> emptyList() + } + + fun canSend(): Boolean = (this == Empty || this is Content) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt new file mode 100644 index 000000000000..017a434602af --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -0,0 +1,207 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository +import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND +import com.nextcloud.utils.date.DateFormatPattern +import com.nextcloud.utils.date.DateFormatter +import com.nextcloud.utils.extensions.isHuman +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : ViewModel() { + + companion object { + private const val POLLING_INTERVAL_MS = 4_000L + } + + private val _uiState = MutableStateFlow(ChatUIState.Empty) + val uiState: StateFlow = _uiState + + private val _sessionTitle = MutableStateFlow(null) + val sessionTitle: StateFlow = _sessionTitle + + private val _sessionId = MutableStateFlow(null) + val sessionId: StateFlow = _sessionId + + private var currentMessages: List = emptyList() + private var currentChatTaskId: String? = null + private var pollingJob: Job? = null + + fun selectConversation(sessionId: Long) { + _sessionId.update { sessionId } + currentChatTaskId = null + + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { ChatUIState.Loading } + + fetchAllMessages(sessionId) + + val session = remoteRepository.checkSession(sessionId.toString()) + currentChatTaskId = session?.taskId?.toString() + + val lastMessageIsHuman = currentMessages.lastOrNull()?.isHuman() == true + + // update session title if exists + session?.sessionTitle?.let { + _sessionTitle.update { + it + } + } + + when { + currentChatTaskId != null && currentChatTaskId != "0" -> startPolling(sessionId) + + lastMessageIsHuman -> _uiState.update { ChatUIState.RetryAvailable(currentMessages) } + + else -> _uiState.update { + if (currentMessages.isEmpty()) { + ChatUIState.Empty + } else { + ChatUIState.Content(currentMessages) + } + } + } + } + } + + fun startPolling(sessionId: Long) { + stopPolling() + _uiState.update { ChatUIState.Thinking(currentMessages) } + + pollingJob = viewModelScope.launch(Dispatchers.IO) { + while (isActive) { + delay(POLLING_INTERVAL_MS) + fetchNewChatMessage(sessionId) + } + } + } + + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + private suspend fun fetchAllMessages(sessionId: Long) { + val messages = remoteRepository.fetchChatMessages(sessionId) + if (messages != null) { + currentMessages = messages + _uiState.update { + if (messages.isEmpty()) ChatUIState.Empty else ChatUIState.Content(messages) + } + } else { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.LoadMessages) } + } + } + + private suspend fun fetchNewChatMessage(sessionId: Long) { + val taskId = currentChatTaskId ?: return + val newMessage = remoteRepository.checkGeneration(taskId, sessionId.toString()) ?: return + + val alreadyExists = currentMessages.any { + it.id == newMessage.id || + (it.timestamp == newMessage.timestamp && it.content == newMessage.content) + } + + if (!alreadyExists && !newMessage.isHuman()) { + currentMessages = currentMessages + newMessage + stopPolling() + _uiState.update { ChatUIState.Content(currentMessages) } + } + } + + fun sendMessage(content: String, sessionId: Long) { + _uiState.update { ChatUIState.Sending(currentMessages) } + viewModelScope.launch(Dispatchers.IO) { + sendMessageInternal(content, sessionId) + } + } + + private suspend fun sendMessageInternal(content: String, sessionId: Long) { + val request = ChatMessageRequest( + sessionId = sessionId.toString(), + role = "human", + content = content, + timestamp = System.currentTimeMillis() / MILLIS_PER_SECOND, + firstHumanMessage = currentMessages.isEmpty() + ) + + val sentMessage = remoteRepository.sendChatMessage(request) + if (sentMessage != null) { + currentMessages = currentMessages + sentMessage + stopPolling() + generateSession(sessionId) + startPolling(sessionId) + } else { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.SendMessage) } + } + } + + private suspend fun generateSession(sessionId: Long) { + remoteRepository.generateSession(sessionId.toString())?.let { + currentChatTaskId = it.taskId.toString() + } + } + + fun retryResponseGeneration() { + val sessionId = _sessionId.value ?: return + viewModelScope.launch(Dispatchers.IO) { + generateSession(sessionId) + startPolling(sessionId) + } + } + + fun startNewConversation(content: String) { + if (_sessionId.value != null) { + sendMessage(content, _sessionId.value!!) + return + } + + _uiState.update { ChatUIState.Sending(currentMessages) } + + viewModelScope.launch(Dispatchers.IO) { + val result = remoteRepository.createConversation(content) ?: run { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.SendMessage) } + return@launch + } + + val newSessionId = result.session.id + _sessionId.update { newSessionId } + currentChatTaskId = null + sendMessageInternal(content, newSessionId) + } + } + + fun updateSessionTitle(timestamp: Long) { + val newSessionTitle = DateFormatter + .timestampToDateRepresentation( + timestamp, + DateFormatPattern.MonthDayYearTime + ) + + _sessionTitle.update { + newSessionTitle + } + } + + override fun onCleared() { + super.onCleared() + stopPolling() + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt new file mode 100644 index 000000000000..d1235c0a6ef5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -0,0 +1,293 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +@file:Suppress("TopLevelPropertyNaming", "MagicNumber") + +package com.nextcloud.client.assistant.conversation + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextcloud.client.assistant.conversation.model.ConversationScreenState +import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.chat.model.Conversation + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, openChat: (Conversation) -> Unit) { + val screenState by viewModel.screenState.collectAsState() + val errorMessageId by viewModel.errorMessageId.collectAsState() + val conversations by viewModel.conversations.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val errorString = errorMessageId?.let { stringResource(it) } + + LaunchedEffect(Unit) { + viewModel.fetchConversations() + } + + LaunchedEffect(errorString) { + errorString?.let { + snackbarHostState.showSnackbar(it) + } + } + + Scaffold( + topBar = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { + close() + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource( + R.string.assistant_screen_conversations_go_back_to_assistant + ) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.conversation_screen_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.weight(1f)) + } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + }, + floatingActionButton = { + FloatingActionButton(onClick = { + viewModel.createConversation(null, onResult = { + viewModel.selectConversation(null) + openChat(it) + }) + }) { + Icon(Icons.Filled.Add, "Floating action button.") + } + }, + floatingActionButtonPosition = FabPosition.EndOverlay + ) { innerPadding -> + when (screenState) { + is ConversationScreenState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is ConversationScreenState.EmptyContent -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Text( + stringResource(R.string.conversation_screen_empty_content_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall + ) + } + } + + else -> { + ConversationList( + viewModel = viewModel, + conversations = conversations, + modifier = Modifier.padding(innerPadding), + openChat = openChat + ) + } + } + } +} + +@Composable +private fun ConversationList( + viewModel: ConversationViewModel, + conversations: List, + modifier: Modifier = Modifier, + openChat: (Conversation) -> Unit +) { + var showConversationActions by remember { mutableStateOf(false) } + val selectedConversationId by viewModel.selectedConversationId.collectAsState() + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.Bottom + ) { + items(conversations) { conversation -> + ConversationListItem( + conversation = conversation, + isSelected = (conversation.id == selectedConversationId), + onClick = { + viewModel.selectConversation(conversation.id) + openChat(conversation) + }, + onLongPressed = { + showConversationActions = true + } + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } + + if (showConversationActions) { + val currentId = selectedConversationId + + val bottomSheetAction = listOf( + Triple( + R.drawable.ic_delete, + R.string.conversation_screen_delete_button_title + ) { + val sessionId: String = currentId.toString() + viewModel.deleteConversation(sessionId) + showConversationActions = false + } + ) + + MoreActionsBottomSheet( + actions = bottomSheetAction, + onDismiss = { showConversationActions = false } + ) + } +} + +@Composable +private fun ConversationListItem( + conversation: Conversation, + isSelected: Boolean, + onClick: () -> Unit, + onLongPressed: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + } + ) + .height(52.dp) + .combinedClickable( + onClick = onClick, + onLongClick = onLongPressed + ), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = conversation.titleRepresentation(), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onSurface + } else { + colorResource(R.color.text_color) + }, + textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) + } +} + +@Preview +@Composable +private fun ConversationListPreview() { + Column { + ConversationListItem( + Conversation( + 1L, + "User1", + "Who is Al Pacino?", + 1762847286L, + "", + null + ), + false, + { + }, + { + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ConversationListItem( + Conversation( + 2L, + "User1", + "What is JetpackCompose?", + 1761847286L, + "", + null + ), + false, + { + }, + { + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt new file mode 100644 index 000000000000..a887f31f5ab5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt @@ -0,0 +1,108 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.conversation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.assistant.conversation.model.ConversationScreenState +import com.nextcloud.client.assistant.conversation.repository.ConversationRemoteRepository +import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.assistant.chat.model.Conversation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ConversationViewModel(private val remoteRepository: ConversationRemoteRepository) : ViewModel() { + private val _selectedConversationId = MutableStateFlow(null) + val selectedConversationId: StateFlow = _selectedConversationId + + private val _errorMessageId = MutableStateFlow(null) + val errorMessageId: StateFlow = _errorMessageId + + private val _screenState = MutableStateFlow(null) + val screenState: StateFlow = _screenState + + private val _conversations = MutableStateFlow>(listOf()) + val conversations: StateFlow> = _conversations.asStateFlow() + + fun selectConversation(value: Long?) { + _selectedConversationId.update { + value + } + } + + fun fetchConversations() { + _screenState.update { + ConversationScreenState.Loading + } + + viewModelScope.launch(Dispatchers.IO) { + val conversations = remoteRepository.fetchConversationList() + if (conversations != null) { + if (conversations.isEmpty()) { + _screenState.update { + ConversationScreenState.emptyConversationList() + } + } else { + _screenState.update { + null + } + _conversations.update { + conversations + } + } + } else { + _screenState.update { + null + } + _errorMessageId.update { + R.string.conversation_screen_fetch_error_title + } + } + } + } + + fun createConversation(title: String?, onResult: (Conversation) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val timestamp = System.currentTimeMillis().div(MILLIS_PER_SECOND) + val newConversation = remoteRepository.createConversation(title, timestamp) + if (newConversation != null) { + _conversations.update { + listOf(newConversation.session) + it + } + onResult(newConversation.session) + } else { + _errorMessageId.update { + R.string.conversation_screen_create_error_title + } + } + } + } + + fun deleteConversation(sessionId: String) { + Log_OC.d("", "BBBB: $sessionId") + viewModelScope.launch(Dispatchers.IO) { + val success = remoteRepository.deleteConversation(sessionId) + if (success) { + val updatedList = _conversations.value.filterNot { it.id == sessionId.toLong() } + _conversations.update { + updatedList + } + } else { + _errorMessageId.update { + R.string.conversation_screen_delete_error_title + } + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/model/ConversationScreenState.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/model/ConversationScreenState.kt new file mode 100644 index 000000000000..259debcf938e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/model/ConversationScreenState.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.conversation.model + +import com.owncloud.android.R + +sealed class ConversationScreenState { + data object Loading : ConversationScreenState() + + data class EmptyContent(val descriptionId: Int) : ConversationScreenState() + + companion object { + fun emptyConversationList(): ConversationScreenState = EmptyContent( + descriptionId = R.string.conversation_screen_empty_conversation_list_title + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepository.kt new file mode 100644 index 000000000000..a0d034d575c3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepository.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.conversation.repository + +import com.owncloud.android.lib.resources.assistant.chat.model.Conversation +import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation + +interface ConversationRemoteRepository { + suspend fun fetchConversationList(): List? + suspend fun createConversation(title: String?, timestamp: Long): CreateConversation? + suspend fun deleteConversation(sessionId: String): Boolean +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepositoryImpl.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepositoryImpl.kt new file mode 100644 index 000000000000..8fa89a2bd05e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepositoryImpl.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.conversation.repository + +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.lib.resources.assistant.chat.CreateConversationRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.DeleteConversationRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.GetConversationListRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.model.Conversation +import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation + +class ConversationRemoteRepositoryImpl(private val client: NextcloudClient) : ConversationRemoteRepository { + override suspend fun fetchConversationList(): List? { + val result = GetConversationListRemoteOperation().execute(client) + return if (result.isSuccess) { + result.resultData + } else { + null + } + } + + override suspend fun createConversation(title: String?, timestamp: Long): CreateConversation? { + val result = CreateConversationRemoteOperation(title, timestamp).execute(client) + return if (result.isSuccess) { + result.resultData + } else { + null + } + } + + override suspend fun deleteConversation(sessionId: String): Boolean { + val result = DeleteConversationRemoteOperation(sessionId).execute(client) + return result.isSuccess + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/MockConversationRemoteRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/MockConversationRemoteRepository.kt new file mode 100644 index 000000000000..42e13346ac8a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/MockConversationRemoteRepository.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.conversation.repository + +import com.owncloud.android.lib.resources.assistant.chat.model.Conversation +import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation + +class MockConversationRemoteRepository : ConversationRemoteRepository { + override suspend fun fetchConversationList(): List? = null + + override suspend fun createConversation(title: String?, timestamp: Long): CreateConversation? = null + + override suspend fun deleteConversation(sessionId: String): Boolean = true +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/extensions/TaskExtensions.kt b/app/src/main/java/com/nextcloud/client/assistant/extensions/TaskExtensions.kt new file mode 100644 index 000000000000..5bc6dacf6a0d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/extensions/TaskExtensions.kt @@ -0,0 +1,201 @@ +/* + * Nextcloud Android client application + * + * SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud + * contributors + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.client.assistant.extensions + +import android.content.Context +import com.nextcloud.utils.date.DateFormatPattern +import com.nextcloud.utils.date.DateFormatter +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OCCapability +import java.util.concurrent.TimeUnit + +fun Task.getInputAndOutput(): String { + val inputText = input?.input ?: "" + val outputText = output?.output ?: "" + + return "$inputText\n\n$outputText" +} + +fun Task.getInput(): String? = input?.input + +@Suppress("MagicNumber") +fun Task.getInputTitle(): String { + val maxTitleLength = 20 + val title = getInput() ?: "" + + return if (title.length > maxTitleLength) { + title.take(maxTitleLength) + "..." + } else { + title + } +} + +fun Task.getStatusIcon(capability: OCCapability): Int = + if (capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30)) { + getStatusIconV2() + } else { + getStatusIconV1() + } + +fun Task.getStatusIconDescription(capability: OCCapability): Int = + if (capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30)) { + getStatusIconDescriptionV2() + } else { + getStatusIconDescriptionV1() + } + +private fun Task.getStatusIconV1(): Int = when (status) { + "0" -> { + R.drawable.ic_unknown + } + + "1" -> { + R.drawable.ic_clock + } + + "2" -> { + R.drawable.ic_modification_desc + } + + "3" -> { + R.drawable.ic_check_circle_outline + } + + "4" -> { + R.drawable.image_fail + } + + else -> { + R.drawable.ic_unknown + } +} + +private fun Task.getStatusIconDescriptionV1(): Int = when (status) { + "0" -> { + R.string.assistant_task_status_unknown + } + + "1" -> { + R.string.assistant_task_status_scheduled + } + + "2" -> { + R.string.assistant_task_status_running + } + + "3" -> { + R.string.assistant_task_status_successful + } + + "4" -> { + R.string.assistant_task_status_failed + } + + else -> { + R.string.assistant_task_status_unknown + } +} + +private fun Task.getStatusIconV2(): Int = when (status) { + "STATUS_UNKNOWN" -> { + R.drawable.ic_unknown + } + + "STATUS_SCHEDULED" -> { + R.drawable.ic_clock + } + + "STATUS_RUNNING" -> { + R.drawable.ic_modification_desc + } + + "STATUS_SUCCESSFUL" -> { + R.drawable.ic_check_circle_outline + } + + "STATUS_FAILED" -> { + R.drawable.image_fail + } + + else -> { + R.drawable.ic_unknown + } +} + +private fun Task.getStatusIconDescriptionV2(): Int = when (status) { + "STATUS_UNKNOWN" -> { + R.string.assistant_task_status_unknown + } + + "STATUS_SCHEDULED" -> { + R.string.assistant_task_status_scheduled + } + + "STATUS_RUNNING" -> { + R.string.assistant_task_status_running + } + + "STATUS_SUCCESSFUL" -> { + R.string.assistant_task_status_successful + } + + "STATUS_FAILED" -> { + R.string.assistant_task_status_failed + } + + else -> { + R.string.assistant_task_status_unknown + } +} + +@Suppress("MagicNumber") +fun Task.getModifiedAtRepresentation(context: Context): String? { + if (lastUpdated == null) { + return null + } + + val modifiedAt = lastUpdated!!.toLong() + val currentTime = System.currentTimeMillis() / 1000 + val timeDifference = (currentTime - modifiedAt).toInt() + val timeDifferenceInMinutes = (timeDifference / 60) + val timeDifferenceInHours = (timeDifference / 3600) + + return when { + timeDifference < 0 -> { + context.getString(R.string.common_now) + } + + timeDifference < TimeUnit.MINUTES.toSeconds(1) -> { + context.resources.getQuantityString(R.plurals.time_seconds_ago, timeDifference, timeDifference) + } + + timeDifference < TimeUnit.HOURS.toSeconds(1) -> { + context.resources.getQuantityString( + R.plurals.time_minutes_ago, + timeDifferenceInMinutes, + timeDifferenceInMinutes + ) + } + + timeDifference < TimeUnit.DAYS.toSeconds(1) -> { + context.resources.getQuantityString( + R.plurals.time_hours_ago, + timeDifferenceInHours, + timeDifferenceInHours + ) + } + + else -> { + DateFormatter.timestampToDateRepresentation(modifiedAt, DateFormatPattern.MonthWithDate) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/model/AssistantPage.kt b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantPage.kt new file mode 100644 index 000000000000..5fd71ae1073a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantPage.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.model + +enum class AssistantPage(val id: Int) { + Conversation(0), + Content(1) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/model/AssistantScreenState.kt b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantScreenState.kt new file mode 100644 index 000000000000..8f0ad7bfd011 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantScreenState.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.model + +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.Task + +sealed class AssistantScreenState { + data object Loading : AssistantScreenState() + + data object TaskContent : AssistantScreenState() + + data object ChatContent : AssistantScreenState() + + data class Translation(val task: Task?) : AssistantScreenState() + + data class EmptyContent(val iconId: Int?, val titleId: Int?, val descriptionId: Int?) : AssistantScreenState() + + companion object { + fun emptyTaskTypes(): AssistantScreenState = EmptyContent( + titleId = null, + descriptionId = R.string.assistant_screen_task_list_empty_warning, + iconId = null + ) + + fun emptyChatList(): AssistantScreenState = EmptyContent( + iconId = R.drawable.ic_assistant, + titleId = R.string.assistant_screen_empty_content_title, + descriptionId = R.string.assistant_screen_empty_content_description + ) + + fun emptyTaskList(): AssistantScreenState = EmptyContent( + iconId = R.drawable.ic_assistant, + titleId = R.string.assistant_screen_empty_content_title, + descriptionId = null + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/model/ScreenOverlayState.kt b/app/src/main/java/com/nextcloud/client/assistant/model/ScreenOverlayState.kt new file mode 100644 index 000000000000..5d63e66dab44 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/model/ScreenOverlayState.kt @@ -0,0 +1,57 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.model + +import android.app.Activity +import com.nextcloud.client.assistant.extensions.getInputAndOutput +import com.nextcloud.utils.extensions.showShareIntent +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.utils.ClipboardUtil + +sealed class ScreenOverlayState { + data class DeleteTask(val id: Long) : ScreenOverlayState() + data class TaskActions(val task: Task) : ScreenOverlayState() { + private fun getInputAndOutput(): String = task.getInputAndOutput() + + private fun getCopyToClipboardAction(activity: Activity): Triple Unit> = Triple( + R.drawable.ic_content_copy, + R.string.common_copy + ) { + ClipboardUtil.copyToClipboard(activity, getInputAndOutput(), showToast = false) + } + + private fun getShareAction(activity: Activity): Triple Unit> = Triple( + R.drawable.ic_share, + R.string.common_share + ) { + activity.showShareIntent(getInputAndOutput()) + } + + private fun getDeleteAction(onComplete: (DeleteTask) -> Unit): Triple Unit> = Triple( + R.drawable.ic_delete, + R.string.assistant_screen_task_more_actions_bottom_sheet_delete_action + ) { + val newState = DeleteTask(task.id) + onComplete(newState) + } + + fun getActions( + activity: Activity, + onDeleteCompleted: (DeleteTask) -> Unit + ): List Unit>> = listOf( + getShareAction(activity), + getCopyToClipboardAction(activity), + getDeleteAction(onComplete = { + onDeleteCompleted(it) + }) + ) + } + data class TaskTypes(val copiedText: String, val taskTypes: List) : ScreenOverlayState() +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepository.kt new file mode 100644 index 000000000000..76568959583e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepository.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.repository.local + +import com.owncloud.android.lib.resources.assistant.v2.model.Task + +interface AssistantLocalRepository { + suspend fun cacheTasks(tasks: List, accountName: String) + suspend fun getCachedTasks(accountName: String, type: String): List + suspend fun insertTask(task: Task, accountName: String) + suspend fun deleteTask(id: Long, accountName: String, type: String) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepositoryImpl.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepositoryImpl.kt new file mode 100644 index 000000000000..70fb3398cee9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepositoryImpl.kt @@ -0,0 +1,69 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.repository.local + +import com.nextcloud.client.database.dao.AssistantDao +import com.nextcloud.client.database.entity.AssistantEntity +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput +import com.owncloud.android.lib.resources.assistant.v2.model.TaskOutput + +class AssistantLocalRepositoryImpl(private val assistantDao: AssistantDao) : AssistantLocalRepository { + + override suspend fun cacheTasks(tasks: List, accountName: String) { + val entities = tasks.map { it.toEntity(accountName) } + assistantDao.insertAssistantTasks(entities) + } + + override suspend fun getCachedTasks(accountName: String, type: String): List { + val entities = assistantDao.getAssistantTasksByAccount(accountName, type) + return entities.map { it.toTask() } + } + + override suspend fun insertTask(task: Task, accountName: String) { + assistantDao.insertAssistantTask(task.toEntity(accountName)) + } + + override suspend fun deleteTask(id: Long, accountName: String, type: String) { + val cached = assistantDao.getAssistantTasksByAccount(accountName, type).firstOrNull { it.id == id } ?: return + assistantDao.deleteAssistantTask(cached) + } + + // region Mapping helpers + private fun Task.toEntity(accountName: String): AssistantEntity = AssistantEntity( + id = this.id, + accountName = accountName, + type = this.type, + status = this.status, + userId = this.userId, + appId = this.appId, + input = this.input?.input, + output = this.output?.output, + completionExpectedAt = this.completionExpectedAt, + progress = this.progress, + lastUpdated = this.lastUpdated, + scheduledAt = this.scheduledAt, + endedAt = this.endedAt + ) + + private fun AssistantEntity.toTask(): Task = Task( + id = this.id, + type = this.type, + status = this.status, + userId = this.userId, + appId = this.appId, + input = TaskInput(input = this.input), + output = TaskOutput(output = this.output), + completionExpectedAt = this.completionExpectedAt, + progress = this.progress, + lastUpdated = this.lastUpdated, + scheduledAt = this.scheduledAt, + endedAt = this.endedAt + ) + // endregion +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/local/MockAssistantLocalRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/local/MockAssistantLocalRepository.kt new file mode 100644 index 000000000000..231534a674a6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/local/MockAssistantLocalRepository.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.repository.local + +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class MockAssistantLocalRepository : AssistantLocalRepository { + + private val tasks = mutableListOf() + private val mutex = Mutex() + + override suspend fun cacheTasks(tasks: List, accountName: String) { + mutex.withLock { + this.tasks.clear() + this.tasks.addAll(tasks) + } + } + + override suspend fun getCachedTasks(accountName: String, type: String): List = + mutex.withLock { tasks.toList() } + + override suspend fun insertTask(task: Task, accountName: String) { + mutex.withLock { tasks.add(task) } + } + + override suspend fun deleteTask(id: Long, accountName: String, type: String) { + mutex.withLock { tasks.removeAll { it.id == id } } + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepository.kt new file mode 100644 index 000000000000..4a578f6b512e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepository.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.repository.remote + +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest +import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation +import com.owncloud.android.lib.resources.assistant.chat.model.Session +import com.owncloud.android.lib.resources.assistant.chat.model.SessionTask +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationRequest + +interface AssistantRemoteRepository { + suspend fun fetchTaskTypes(): List? + + suspend fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult + + suspend fun getTaskList(taskType: String): List? + + suspend fun deleteTask(id: Long): RemoteOperationResult + + suspend fun fetchChatMessages(id: Long): List? + + suspend fun sendChatMessage(request: ChatMessageRequest): ChatMessage? + + suspend fun createConversation(title: String): CreateConversation? + + suspend fun checkSession(sessionId: String): Session? + + suspend fun generateSession(sessionId: String): SessionTask? + + suspend fun checkGeneration(taskId: String, sessionId: String): ChatMessage? + + suspend fun translate(input: TranslationRequest, taskType: TaskTypeData): RemoteOperationResult +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepositoryImpl.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepositoryImpl.kt new file mode 100644 index 000000000000..596e5353b3b9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepositoryImpl.kt @@ -0,0 +1,134 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.assistant.repository.remote + +import com.nextcloud.common.NextcloudClient +import com.nextcloud.utils.TimeConstants +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.resources.assistant.chat.CheckGenerationRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.CheckSessionRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.CreateConversationRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.CreateMessageRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.GenerateSessionRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.GetMessagesRemoteOperation +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest +import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation +import com.owncloud.android.lib.resources.assistant.chat.model.Session +import com.owncloud.android.lib.resources.assistant.chat.model.SessionTask +import com.owncloud.android.lib.resources.assistant.v1.CreateTaskRemoteOperationV1 +import com.owncloud.android.lib.resources.assistant.v1.DeleteTaskRemoteOperationV1 +import com.owncloud.android.lib.resources.assistant.v1.GetTaskListRemoteOperationV1 +import com.owncloud.android.lib.resources.assistant.v1.GetTaskTypesRemoteOperationV1 +import com.owncloud.android.lib.resources.assistant.v1.model.toV2 +import com.owncloud.android.lib.resources.assistant.v2.CreateTaskRemoteOperationV2 +import com.owncloud.android.lib.resources.assistant.v2.CreateTranslationTaskRemoteOperation +import com.owncloud.android.lib.resources.assistant.v2.DeleteTaskRemoteOperationV2 +import com.owncloud.android.lib.resources.assistant.v2.GetTaskListRemoteOperationV2 +import com.owncloud.android.lib.resources.assistant.v2.GetTaskTypesRemoteOperationV2 +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationRequest +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OCCapability +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AssistantRemoteRepositoryImpl(private val client: NextcloudClient, capability: OCCapability) : + AssistantRemoteRepository { + + private val supportsV2 = capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30) + + override suspend fun fetchTaskTypes(): List? = withContext(Dispatchers.IO) { + if (supportsV2) { + val result = GetTaskTypesRemoteOperationV2().execute(client) + if (result.isSuccess) { + return@withContext result.resultData + } + } else { + val result = GetTaskTypesRemoteOperationV1().execute(client) + if (result.isSuccess) { + return@withContext result.resultData.toV2() + } + } + return@withContext null + } + + override suspend fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult = + withContext(Dispatchers.IO) { + if (supportsV2) { + CreateTaskRemoteOperationV2(input, taskType).execute(client) + } else { + if (taskType.id.isNullOrEmpty()) { + RemoteOperationResult(ResultCode.CANCELLED) + } else { + CreateTaskRemoteOperationV1(input, taskType.id!!).execute(client) + } + } + } + + override suspend fun getTaskList(taskType: String): List? = withContext(Dispatchers.IO) { + if (supportsV2) { + val result = GetTaskListRemoteOperationV2(taskType).execute(client) + if (result.isSuccess) { + return@withContext result.resultData.tasks.filter { it.appId == "assistant" } + } + } else { + val result = GetTaskListRemoteOperationV1("assistant").execute(client) + if (result.isSuccess) { + return@withContext result.resultData.toV2().tasks.filter { it.type == taskType } + } + } + return@withContext null + } + + override suspend fun deleteTask(id: Long): RemoteOperationResult = withContext(Dispatchers.IO) { + if (supportsV2) { + DeleteTaskRemoteOperationV2(id).execute(client) + } else { + DeleteTaskRemoteOperationV1(id).execute(client) + } + } + + override suspend fun fetchChatMessages(id: Long): List? = withContext(Dispatchers.IO) { + val result = GetMessagesRemoteOperation(id.toString()).execute(client) + if (result.isSuccess) result.resultData else null + } + + override suspend fun sendChatMessage(request: ChatMessageRequest): ChatMessage? = withContext(Dispatchers.IO) { + val result = CreateMessageRemoteOperation(request).execute(client) + if (result.isSuccess) result.resultData else null + } + + override suspend fun createConversation(title: String): CreateConversation? = withContext(Dispatchers.IO) { + val timestamp = System.currentTimeMillis().div(TimeConstants.MILLIS_PER_SECOND) + val result = CreateConversationRemoteOperation(title, timestamp).execute(client) + if (result.isSuccess) result.resultData else null + } + + override suspend fun checkSession(sessionId: String): Session? = withContext(Dispatchers.IO) { + val result = CheckSessionRemoteOperation(sessionId).execute(client) + if (result.isSuccess) result.resultData else null + } + + override suspend fun generateSession(sessionId: String): SessionTask? = withContext(Dispatchers.IO) { + val result = GenerateSessionRemoteOperation(sessionId).execute(client) + if (result.isSuccess) result.resultData else null + } + + override suspend fun checkGeneration(taskId: String, sessionId: String): ChatMessage? = + withContext(Dispatchers.IO) { + val result = CheckGenerationRemoteOperation(taskId, sessionId).execute(client) + if (result.isSuccess) result.resultData else null + } + + override suspend fun translate(input: TranslationRequest, taskType: TaskTypeData): RemoteOperationResult = + withContext(Dispatchers.IO) { + CreateTranslationTaskRemoteOperation(input, taskType).execute(client) + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/MockAssistantRemoteRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/MockAssistantRemoteRepository.kt new file mode 100644 index 000000000000..0788cc4f8535 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/MockAssistantRemoteRepository.kt @@ -0,0 +1,82 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.assistant.repository.remote + +import com.nextcloud.utils.extensions.getRandomString +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest +import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation +import com.owncloud.android.lib.resources.assistant.chat.model.Session +import com.owncloud.android.lib.resources.assistant.chat.model.SessionTask +import com.owncloud.android.lib.resources.assistant.v2.model.Shape +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput +import com.owncloud.android.lib.resources.assistant.v2.model.TaskOutput +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationRequest + +@Suppress("MagicNumber") +class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false) : AssistantRemoteRepository { + override suspend fun fetchTaskTypes(): List = listOf( + TaskTypeData( + id = "core:text2text", + name = "Free text to text prompt", + description = "Runs an arbitrary prompt through a language model that returns a reply", + inputShape = mapOf( + "input" to Shape( + name = "Prompt", + description = "Describe a task that you want the assistant to do or ask a question", + type = "Text" + ) + ), + outputShape = mapOf( + "output" to Shape( + name = "Generated reply", + description = "The generated text from the assistant", + type = "Text" + ) + ) + ) + ) + + override suspend fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult = + RemoteOperationResult(RemoteOperationResult.ResultCode.OK) + + override suspend fun getTaskList(taskType: String): List = if (giveEmptyTasks) { + listOf() + } else { + listOf( + Task( + 1, + "FreePrompt", + null, + "12", + "", + TaskInput("Give me some long text 1"), + TaskOutput("Lorem ipsum".getRandomString(100)), + 1707692337, + 1707692337, + 1707692337, + 1707692337, + 1707692337 + ) + ) + } + + override suspend fun deleteTask(id: Long): RemoteOperationResult = + RemoteOperationResult(RemoteOperationResult.ResultCode.OK) + override suspend fun fetchChatMessages(id: Long): List = emptyList() + override suspend fun sendChatMessage(request: ChatMessageRequest): ChatMessage? = null + override suspend fun createConversation(title: String): CreateConversation? = null + override suspend fun checkSession(sessionId: String): Session? = null + override suspend fun generateSession(sessionId: String): SessionTask? = null + + override suspend fun checkGeneration(taskId: String, sessionId: String): ChatMessage? = null + override suspend fun translate(input: TranslationRequest, taskType: TaskTypeData): RemoteOperationResult = + RemoteOperationResult(RemoteOperationResult.ResultCode.OK) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/task/TaskStatusView.kt b/app/src/main/java/com/nextcloud/client/assistant/task/TaskStatusView.kt new file mode 100644 index 000000000000..81d85032b1b8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/task/TaskStatusView.kt @@ -0,0 +1,157 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.assistant.task + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextcloud.client.assistant.extensions.getModifiedAtRepresentation +import com.nextcloud.client.assistant.extensions.getStatusIcon +import com.nextcloud.client.assistant.extensions.getStatusIconDescription +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput +import com.owncloud.android.lib.resources.assistant.v2.model.TaskOutput +import com.owncloud.android.lib.resources.status.OCCapability +import java.util.concurrent.TimeUnit + +@Composable +fun TaskStatusView(task: Task, capability: OCCapability) { + val context = LocalContext.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val iconId = task.getStatusIcon(capability) + val iconDescriptionId = task.getStatusIconDescription(capability) + val description = task.getModifiedAtRepresentation(context) + + Image( + painter = painterResource(id = iconId), + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(color = colorResource(R.color.text_color)), + contentDescription = stringResource( + R.string.assistant_task_status_text, + stringResource(iconDescriptionId) + ) + ) + + description?.let { + Spacer(modifier = Modifier.width(6.dp)) + Text(text = description, color = colorResource(R.color.text_color)) + } + } +} + +@Suppress("LongMethod", "MagicNumber") +@Composable +@Preview +private fun TaskStatusViewPreview() { + val currentTime = System.currentTimeMillis() / 1000 + + val tasks = listOf( + Task( + id = 1L, + type = "type1", + status = "STATUS_RUNNING", + userId = "user1", + appId = "app1", + input = TaskInput("input1"), + output = TaskOutput("output1"), + scheduledAt = currentTime.toInt(), + lastUpdated = currentTime.toInt() + ), + + Task( + id = 2L, + type = "type2", + status = "STATUS_SUCCESSFUL", + userId = "user2", + appId = "app2", + input = TaskInput("input2"), + output = TaskOutput("output2"), + lastUpdated = (currentTime - TimeUnit.MINUTES.toSeconds(5)).toInt() + ), + + Task( + id = 3L, + type = "type3", + status = "STATUS_RUNNING", + userId = "user3", + appId = "app3", + input = TaskInput("input3"), + output = TaskOutput("output3"), + lastUpdated = (currentTime - TimeUnit.HOURS.toSeconds(5)).toInt() + ), + + Task( + id = 4L, + type = "type4", + status = "STATUS_SUCCESSFUL", + userId = "user4", + appId = "app4", + input = TaskInput("input4"), + output = TaskOutput("output4"), + lastUpdated = (currentTime - TimeUnit.DAYS.toSeconds(5)).toInt() + ), + + Task( + id = 5L, + type = "type5", + status = "STATUS_SUCCESSFUL", + userId = "user5", + appId = "app5", + input = TaskInput("input5"), + output = TaskOutput("output5"), + lastUpdated = (currentTime - TimeUnit.DAYS.toSeconds(60)).toInt() + ), + + Task( + id = 6L, + type = "type7", + status = "STATUS_UNKNOWN", + userId = "user7", + appId = "app7", + input = TaskInput("input7"), + output = TaskOutput("output7"), + scheduledAt = null, + lastUpdated = null + ) + ) + + LazyColumn { + items(tasks) { + TaskStatusView( + it, + OCCapability().apply { + versionMayor = 30 + } + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt b/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt new file mode 100644 index 000000000000..508839ef10c0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt @@ -0,0 +1,164 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.assistant.task + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.client.assistant.AssistantViewModel +import com.nextcloud.client.assistant.getMockAssistantViewModel +import com.nextcloud.client.assistant.model.AssistantScreenState +import com.nextcloud.client.assistant.taskDetail.TaskDetailBottomSheet +import com.nextcloud.utils.extensions.truncateWithEllipsis +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput +import com.owncloud.android.lib.resources.assistant.v2.model.TaskOutput +import com.owncloud.android.lib.resources.status.OCCapability + +@Suppress("LongMethod", "MagicNumber") +@Composable +fun TaskView(task: Task, viewModel: AssistantViewModel, capability: OCCapability, showTaskActions: () -> Unit) { + var showTaskDetailBottomSheet by remember { mutableStateOf(false) } + + Box { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(color = colorResource(R.color.task_container)) + .clickable { + viewModel.selectTask(task) + + if (task.isTranslate()) { + viewModel.updateTranslationTaskState(true) + viewModel.updateScreenState(AssistantScreenState.Translation(task)) + } else { + showTaskDetailBottomSheet = true + } + } + .padding(16.dp) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + task.input?.input?.let { + Text( + text = it.truncateWithEllipsis(30), + color = colorResource(R.color.text_color), + fontSize = 18.sp, + textAlign = TextAlign.Left, + maxLines = 1, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(300.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + task.output?.output?.let { + Text( + text = it.truncateWithEllipsis(100), + fontSize = 18.sp, + color = colorResource(R.color.text_color), + textAlign = TextAlign.Left, + modifier = Modifier + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + ) + } + + TaskStatusView(task, capability) + + if (showTaskDetailBottomSheet) { + TaskDetailBottomSheet(task, showTaskActions = { + showTaskDetailBottomSheet = false + showTaskActions() + }) { + // task is unselected + viewModel.selectTask(null) + showTaskDetailBottomSheet = false + } + } + } + + IconButton( + modifier = Modifier.align(Alignment.TopEnd), + onClick = showTaskActions + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.overflow_menu), + tint = colorResource(R.color.text_color) + ) + } + } +} + +@Suppress("MagicNumber") +@Preview +@Composable +private fun TaskViewPreview() { + TaskView( + task = Task( + 1, + "Free Prompt", + "STATUS_COMPLETED", + "1", + "1", + TaskInput("What about other promising tokens like"), + TaskOutput( + "Several tokens show promise for future growth in the" + + " cryptocurrency market" + ), + 1707692337, + 1707692337, + 1707692337, + 1707692337, + 1707692337 + ), + viewModel = getMockAssistantViewModel(true), + OCCapability().apply { + versionMayor = 30 + }, + showTaskActions = { + } + ) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt b/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt new file mode 100644 index 000000000000..47dc45c519b0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt @@ -0,0 +1,187 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.assistant.taskDetail + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.utils.extensions.getRandomString +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput +import com.owncloud.android.lib.resources.assistant.v2.model.TaskOutput + +@Suppress("LongMethod") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun TaskDetailBottomSheet(task: Task, showTaskActions: () -> Unit, dismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + modifier = Modifier.padding(top = 32.dp), + containerColor = colorResource(R.color.bg_default), + onDismissRequest = { dismiss() }, + sheetState = sheetState + ) { + Box { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + stickyHeader { + Row( + modifier = Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.weight(1f)) + + IconButton(onClick = showTaskActions) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.overflow_menu), + tint = colorResource(R.color.text_color) + ) + } + } + } + + item { + InputOutputCard(task) + } + } + + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_assistant), + contentDescription = null, + modifier = Modifier.size(12.dp) + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + modifier = Modifier.widthIn(max = 300.dp), + text = stringResource(R.string.assistant_output_generation_warning_text), + color = colorResource(R.color.text_color), + fontSize = 11.sp, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +fun InputOutputCard(task: Task) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent, shape = RoundedCornerShape(8.dp)) + ) { + TitleDescriptionBox( + title = stringResource(R.string.assistant_task_detail_screen_input_button_title), + description = task.input?.input ?: "" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TitleDescriptionBox( + title = stringResource(R.string.assistant_task_detail_screen_output_button_title), + description = task.output?.output ?: stringResource(R.string.assistant_screen_task_output_empty_text) + ) + } +} + +@Composable +private fun TitleDescriptionBox(title: String, description: String?) { + Text( + text = title, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = colorResource(R.color.text_color) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .background(color = colorResource(R.color.task_container), RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + Text( + text = description ?: "", + color = colorResource(R.color.text_color) + ) + } +} + +@Suppress("MagicNumber") +@Preview +@Composable +private fun TaskDetailScreenPreview() { + TaskDetailBottomSheet( + task = Task( + 1, + "Free Prompt", + null, + "1", + "1", + TaskInput("Give me text".getRandomString(100)), + TaskOutput("output".getRandomString(300)), + 1707692337, + 1707692337, + 1707692337, + 1707692337, + 1707692337 + ), + showTaskActions = { + } + ) { + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt b/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt new file mode 100644 index 000000000000..8824eaec8226 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt @@ -0,0 +1,103 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.assistant.taskTypes + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData + +@SuppressLint("ResourceType") +@Composable +fun TaskTypesRow( + selectedTaskType: TaskTypeData?, + data: List, + selectTaskType: (TaskTypeData) -> Unit, + navigateToConversationList: () -> Unit +) { + if (data.isEmpty()) { + return + } + + val selectedTabIndex = data.indexOfFirst { it.id == selectedTaskType?.id }.takeIf { it >= 0 } ?: 0 + + Row( + modifier = Modifier.background(color = MaterialTheme.colorScheme.surface), + horizontalArrangement = Arrangement.Center + ) { + if (data.any { it.isChat() }) { + Spacer(modifier = Modifier.width(11.dp)) + + IconButton( + onClick = { navigateToConversationList() } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_history_back_arrow), + contentDescription = stringResource(R.string.assistant_screen_task_bar_open_conversation_list), + tint = colorResource(R.color.text_color) + ) + } + } + + PrimaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 0.dp, + containerColor = MaterialTheme.colorScheme.surface, + indicator = { + TabRowDefaults.SecondaryIndicator( + Modifier.tabIndicatorOffset(selectedTabIndex), + color = MaterialTheme.colorScheme.primary + ) + } + ) { + data.forEach { taskType -> + Tab( + selected = selectedTaskType?.id == taskType.id, + onClick = { selectTaskType(taskType) }, + selectedContentColor = colorResource(R.color.text_color), + unselectedContentColor = colorResource(R.color.disabled_text), + text = { Text(text = taskType.name) } + ) + } + } + } +} + +@Composable +@Preview +private fun TaskTypesRowPreview() { + val selectedTaskType = TaskTypeData("1", "Free text to text prompt", "", emptyMap(), emptyMap()) + val taskTypes = listOf( + TaskTypeData("1", "Free text to text prompt", "", emptyMap(), emptyMap()), + TaskTypeData("2", "Extract topics", "", emptyMap(), emptyMap()), + TaskTypeData("3", "Generate Headline", "", emptyMap(), emptyMap()), + TaskTypeData("4", "Summarize", "", emptyMap(), emptyMap()) + ) + + TaskTypesRow(selectedTaskType, taskTypes, { + }, { + }) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreen.kt new file mode 100644 index 000000000000..63a75e7b36dd --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreen.kt @@ -0,0 +1,264 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.translate + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nextcloud.client.assistant.AssistantViewModel +import com.nextcloud.client.assistant.model.AssistantScreenState +import com.nextcloud.utils.extensions.getActivity +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationLanguage +import com.owncloud.android.lib.resources.assistant.v2.model.toTranslationLanguages +import com.owncloud.android.utils.ClipboardUtil + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TranslationScreen(viewModel: TranslationViewModel, assistantViewModel: AssistantViewModel) { + val state by viewModel.screenState.collectAsState() + val messageId by viewModel.snackbarMessageId.collectAsState() + val message = messageId?.let { stringResource(it) } + val snackbarHostState = remember { SnackbarHostState() } + + BackHandler { + assistantViewModel.onTranslationScreenDismissed() + assistantViewModel.updateScreenState(AssistantScreenState.TaskContent) + } + + LaunchedEffect(message) { + message?.let { + snackbarHostState.showSnackbar(it) + viewModel.updateSnackbarMessage(null) + } + } + + // task is unselected + DisposableEffect(Unit) { + onDispose { + assistantViewModel.onTranslationScreenDismissed() + } + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .padding(top = 32.dp), + floatingActionButton = { + if (state.fabVisibility) { + FloatingActionButton(onClick = { + viewModel.translate() + }, content = { + Icon(painter = painterResource(R.drawable.ic_translate), contentDescription = "translate button") + }) + } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + } + ) { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues)) { + item { + TranslationSection( + labelId = R.string.translation_screen_label_from, + hintId = R.string.translation_screen_hint_source, + state = state.source, + availableLanguages = state.taskTypeData.toTranslationLanguages().originLanguages, + maxDp = 120.dp, + shimmer = false, + onStateChange = { + viewModel.updateSourceState(it) + } + ) + } + + item { + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + item { + TranslationSection( + labelId = R.string.translation_screen_label_to, + hintId = state.targetHintMessageId, + state = state.target, + availableLanguages = state.taskTypeData.toTranslationLanguages().targetLanguages, + maxDp = Dp.Unspecified, + shimmer = state.shimmer, + onStateChange = { + viewModel.updateTargetState(it) + } + ) + } + } + } +} + +@Suppress("LongMethod", "LongParameterList") +@Composable +private fun TranslationSection( + labelId: Int, + hintId: Int?, + state: TranslationSideState, + availableLanguages: List, + maxDp: Dp, + shimmer: Boolean, + onStateChange: (TranslationSideState) -> Unit +) { + val activity = LocalContext.current.getActivity() + + Row( + modifier = Modifier + .padding(16.dp) + .clickable { onStateChange(state.copy(isExpanded = !state.isExpanded)) }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(labelId), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = state.language?.name ?: "", + style = MaterialTheme.typography.labelLarge + ) + Icon( + painter = painterResource(R.drawable.ic_baseline_arrow_drop_down_24), + contentDescription = "dropdown icon", + modifier = Modifier + .padding(start = 4.dp) + .size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + DropdownMenu( + expanded = state.isExpanded, + onDismissRequest = { onStateChange(state.copy(isExpanded = false)) } + ) { + availableLanguages.forEach { language -> + DropdownMenuItem( + text = { Text(language.name) }, + onClick = { + onStateChange(state.copy(language = language, isExpanded = false)) + } + ) + } + } + + if (state.isTarget && state.text.isNotBlank()) { + Spacer(modifier = Modifier.weight(1f)) + + IconButton(onClick = { + activity?.let { ClipboardUtil.copyToClipboard(it, state.text, true) } + }) { + Icon(painter = painterResource(R.drawable.ic_content_copy), contentDescription = "copy button") + } + } + } + + if (state.isTarget && shimmer) { + TranslatingShimmer( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp) + .padding(horizontal = 16.dp) + ) + } else { + TextField( + value = state.text, + onValueChange = { onStateChange(state.copy(text = it)) }, + readOnly = state.isTarget, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = maxDp), + placeholder = { + hintId?.let { Text(text = stringResource(it), style = MaterialTheme.typography.headlineSmall) } + }, + textStyle = MaterialTheme.typography.headlineSmall, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun TranslatingShimmer(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "shimmer") + val alpha by transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(900), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + Column(modifier = modifier) { + Text( + text = stringResource(R.string.translation_screen_translating), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + modifier = Modifier.padding(vertical = 4.dp) + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreenState.kt b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreenState.kt new file mode 100644 index 000000000000..ae69aebd3f7f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreenState.kt @@ -0,0 +1,200 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.translate + +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.toTranslationLanguages + +@Suppress("LongParameterList") +sealed class TranslationScreenState( + open val taskTypeData: TaskTypeData, + open val source: TranslationSideState, + open val target: TranslationSideState, + open val fabVisibility: Boolean, + open val shimmer: Boolean, + open val targetHintMessageId: Int? +) + +data object Uninitialized : TranslationScreenState( + taskTypeData = TaskTypeData(null, "", null, mapOf(), mapOf()), + source = TranslationSideState( + text = "", + language = null, + isTarget = false + ), + target = TranslationSideState( + text = "", + language = null, + isTarget = true + ), + fabVisibility = false, + shimmer = false, + targetHintMessageId = null +) + +data class NewTranslation( + override val taskTypeData: TaskTypeData, + override val source: TranslationSideState, + override val target: TranslationSideState, + override val shimmer: Boolean = false +) : TranslationScreenState( + taskTypeData = taskTypeData, + source = source, + target = target, + fabVisibility = true, + shimmer = shimmer, + targetHintMessageId = R.string.translation_screen_start_to_translate_task +) { + companion object { + fun create(taskTypeData: TaskTypeData, textToTranslate: String): NewTranslation = NewTranslation( + taskTypeData = taskTypeData, + source = TranslationSideState( + text = textToTranslate, + language = taskTypeData.toTranslationLanguages().originLanguages.firstOrNull(), + isTarget = false + ), + target = TranslationSideState( + text = "", + language = taskTypeData.toTranslationLanguages().targetLanguages.firstOrNull(), + isTarget = true + ) + ) + } +} + +data class ExistingTranslation( + override val taskTypeData: TaskTypeData, + override val source: TranslationSideState, + override val target: TranslationSideState, + override val shimmer: Boolean = false +) : TranslationScreenState( + taskTypeData = taskTypeData, + source = source, + target = target, + fabVisibility = false, + shimmer = shimmer, + targetHintMessageId = null +) { + companion object { + fun create(taskTypeData: TaskTypeData, textToTranslate: String, translatedText: String): ExistingTranslation = + ExistingTranslation( + taskTypeData = taskTypeData, + source = TranslationSideState( + text = textToTranslate, + language = taskTypeData.toTranslationLanguages().originLanguages.firstOrNull(), + isTarget = false + ), + target = TranslationSideState( + text = translatedText, + language = taskTypeData.toTranslationLanguages().targetLanguages.firstOrNull(), + isTarget = true + ) + ) + } +} + +data class EditedTranslation( + override val taskTypeData: TaskTypeData, + override val source: TranslationSideState, + override val target: TranslationSideState, + override val shimmer: Boolean = false +) : TranslationScreenState( + taskTypeData = taskTypeData, + source = source, + target = target, + fabVisibility = true, + shimmer = shimmer, + targetHintMessageId = null +) { + companion object { + fun create(taskTypeData: TaskTypeData, textToTranslate: String, translatedText: String): EditedTranslation = + EditedTranslation( + taskTypeData = taskTypeData, + source = TranslationSideState( + text = textToTranslate, + language = taskTypeData.toTranslationLanguages().originLanguages.firstOrNull(), + isTarget = false + ), + target = TranslationSideState( + text = translatedText, + language = taskTypeData.toTranslationLanguages().targetLanguages.firstOrNull(), + isTarget = true + ) + ) + } +} + +fun TranslationScreenState.withShimmer(shimmer: Boolean): TranslationScreenState = when (this) { + is NewTranslation -> copy(shimmer = shimmer) + + is ExistingTranslation -> copy(shimmer = shimmer) + + is EditedTranslation -> copy(shimmer = shimmer) + + Uninitialized -> { + Uninitialized + } +} + +fun TranslationScreenState.withTargetText(text: String): TranslationScreenState = when (this) { + is NewTranslation -> EditedTranslation( + taskTypeData = taskTypeData, + source = source, + target = target.copy(text = text), + shimmer = shimmer + ) + + is ExistingTranslation -> copy( + target = target.copy(text = text) + ) + + is EditedTranslation -> copy( + target = target.copy(text = text) + ) + + Uninitialized -> { + Uninitialized + } +} + +fun TranslationScreenState.withSource(newSource: TranslationSideState): TranslationScreenState = when (this) { + is NewTranslation -> copy(source = newSource) + + is ExistingTranslation -> EditedTranslation( + taskTypeData = taskTypeData, + source = newSource, + target = target, + shimmer = shimmer + ) + + is EditedTranslation -> copy(source = newSource) + + Uninitialized -> { + Uninitialized + } +} + +fun TranslationScreenState.withTarget(newTarget: TranslationSideState): TranslationScreenState = when (this) { + is NewTranslation -> { + copy(target = newTarget) + } + + is ExistingTranslation -> EditedTranslation( + taskTypeData = taskTypeData, + source = source, + target = newTarget, + shimmer = shimmer + ) + + is EditedTranslation -> copy(target = newTarget) + + Uninitialized -> { + Uninitialized + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationSideState.kt b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationSideState.kt new file mode 100644 index 000000000000..b39e8dab7378 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationSideState.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.translate + +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationLanguage + +data class TranslationSideState( + val text: String = "", + val language: TranslationLanguage? = null, + val isExpanded: Boolean = false, + val isTarget: Boolean +) diff --git a/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationViewModel.kt new file mode 100644 index 000000000000..df78ef73a35f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationViewModel.kt @@ -0,0 +1,133 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.translate + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationRequest +import com.owncloud.android.lib.resources.assistant.v2.model.toTranslationModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class TranslationViewModel(private val remoteRepository: AssistantRemoteRepository) : ViewModel() { + + companion object { + private const val TAG = "TranslationViewModel" + private const val POLLING_INTERVAL_MS = 15_000L + private const val MAX_RETRY = 3 + } + + private var _screenState = + MutableStateFlow(Uninitialized) + val screenState: StateFlow + get() = _screenState + + private val _snackbarMessageId = MutableStateFlow(null) + val snackbarMessageId: StateFlow = _snackbarMessageId + + private lateinit var taskTypeData: TaskTypeData + private var task: Task? = null + private var textToTranslate = "" + private var translatedText = "" + + fun init(taskTypeData: TaskTypeData, task: Task?, textToTranslate: String) { + this.task = task + this.textToTranslate = textToTranslate + this.taskTypeData = taskTypeData + + _screenState = if (task == null) { + MutableStateFlow(NewTranslation.create(taskTypeData, textToTranslate)) + } else { + val translatedText = task.output?.output ?: "" + this.translatedText = translatedText + MutableStateFlow(ExistingTranslation.create(taskTypeData, textToTranslate, translatedText)) + } + } + + fun translate() { + viewModelScope.launch(Dispatchers.IO) { + val stateValue = _screenState.value + val textToTranslate = stateValue.source.text + val originLanguage = stateValue.source.language ?: return@launch + val targetLanguage = stateValue.target.language ?: return@launch + + val model = taskTypeData.toTranslationModel() + + val input = TranslationRequest( + input = textToTranslate, + originLanguage = originLanguage.code, + targetLanguage = targetLanguage.code, + maxTokens = model?.maxTokens ?: 0.0, + model = model?.model ?: "" + ) + + val result = remoteRepository.translate(input, taskTypeData) + if (result.isSuccess) { + _screenState.update { it.withShimmer(true) } + pollTranslationResult() + _screenState.update { it.withShimmer(false) } + } + } + } + + @Suppress("ReturnCount") + private suspend fun pollTranslationResult() { + val screenStateValue = _screenState.value + if (screenStateValue is Uninitialized) { + return + } + + val taskTypeId = taskTypeData.id ?: return + val input = screenStateValue.source.text + + repeat(MAX_RETRY) { attempt -> + val translationTasks = remoteRepository.getTaskList(taskTypeId) + val translationResult = translationTasks + ?.find { it.input?.input == input } + ?.output + ?.output + + if (!translationResult.isNullOrBlank()) { + _screenState.update { it.withTargetText(translationResult) } + return + } + + Log_OC.d(TAG, "Translation not ready yet (attempt ${attempt + 1}/$MAX_RETRY)") + + if (attempt < MAX_RETRY - 1) { + delay(POLLING_INTERVAL_MS) + } + } + + Log_OC.w(TAG, "Translation polling finished but result is still empty") + updateSnackbarMessage(R.string.translation_screen_task_processing) + } + + fun updateSnackbarMessage(value: Int?) { + _snackbarMessageId.update { + value + } + } + + fun updateSourceState(newSourceState: TranslationSideState) { + _screenState.update { it.withSource(newSourceState) } + } + + fun updateTargetState(newTargetState: TranslationSideState) { + _screenState.update { it.withTarget(newTargetState) } + } +} diff --git a/app/src/main/java/com/nextcloud/client/core/AsyncRunner.kt b/app/src/main/java/com/nextcloud/client/core/AsyncRunner.kt new file mode 100644 index 000000000000..156299c31fb4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/AsyncRunner.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +typealias OnResultCallback = (result: T) -> Unit +typealias OnErrorCallback = (error: Throwable) -> Unit +typealias OnProgressCallback

= (progress: P) -> Unit +typealias IsCancelled = () -> Boolean +typealias TaskFunction = ( + onProgress: OnProgressCallback, + isCancelled: IsCancelled +) -> RESULT + +/** + * This interface allows to post background tasks that report results via callbacks invoked on main thread. + * It is provided as an alternative for heavy, platform specific and virtually untestable [android.os.AsyncTask] + * + * Please note that as of Android R, [android.os.AsyncTask] is deprecated and [java.util.concurrent] is a recommended + * alternative. + */ +interface AsyncRunner { + + /** + * Post a quick background task and return immediately returning task cancellation interface. + * + * Quick task is a short piece of code that does not support interruption nor progress monitoring. + * + * @param task Task function returning result T; error shall be signalled by throwing an exception. + * @param onResult Callback called when task function returns a result. + * @param onError Callback called when task function throws an exception. + * @return Cancellable interface, allowing cancellation of a running task. + */ + fun postQuickTask( + task: () -> RESULT, + onResult: OnResultCallback? = null, + onError: OnErrorCallback? = null + ): Cancellable + + /** + * Post a background task and return immediately returning task cancellation interface. + * + * @param task Task function returning result T; error shall be signalled by throwing an exception. + * @param onResult Callback called when task function returns a result, + * @param onError Callback called when task function throws an exception. + * @param onProgress Callback called when task function reports progress update. + * @return Cancellable interface, allowing cancellation of a running task. + */ + fun postTask( + task: TaskFunction, + onResult: OnResultCallback? = null, + onError: OnErrorCallback? = null, + onProgress: OnProgressCallback? = null + ): Cancellable +} diff --git a/app/src/main/java/com/nextcloud/client/core/Cancellable.kt b/app/src/main/java/com/nextcloud/client/core/Cancellable.kt new file mode 100644 index 000000000000..330d0fe590f7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/Cancellable.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +/** + * Interface allowing cancellation of a running task. + * Once must be careful when cancelling a non-idempotent task, + * as cancellation does not guarantee a task termination. + * One trivial case would be a task finished and cancelled + * before result delivery. + * + * @see [com.nextcloud.client.core.AsyncRunner] + */ +interface Cancellable { + + /** + * Cancel running task. Task termination is not guaranteed, as some + * tasks cannot be interrupted, but the result will not be delivered. + */ + fun cancel() +} diff --git a/app/src/main/java/com/nextcloud/client/core/Clock.kt b/app/src/main/java/com/nextcloud/client/core/Clock.kt new file mode 100644 index 000000000000..07d8c5072c2d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/Clock.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +import java.util.Date +import java.util.TimeZone + +interface Clock { + val currentTime: Long + val currentDate: Date + val millisSinceBoot: Long + val tz: TimeZone +} diff --git a/app/src/main/java/com/nextcloud/client/core/ClockImpl.kt b/app/src/main/java/com/nextcloud/client/core/ClockImpl.kt new file mode 100644 index 000000000000..71c252e800ae --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/ClockImpl.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +import android.os.SystemClock +import java.util.Date +import java.util.TimeZone + +class ClockImpl : Clock { + override val currentTime: Long + get() = System.currentTimeMillis() + + override val currentDate: Date + get() = Date(currentTime) + + override val millisSinceBoot: Long + get() = SystemClock.elapsedRealtime() + + override val tz: TimeZone + get() = TimeZone.getDefault() +} diff --git a/app/src/main/java/com/nextcloud/client/core/LocalBinder.kt b/app/src/main/java/com/nextcloud/client/core/LocalBinder.kt new file mode 100644 index 000000000000..d3b6a0903dd9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/LocalBinder.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +import android.app.Service +import android.os.Binder + +/** + * This is a generic binder that provides access to a locally bound service instance. + */ +abstract class LocalBinder(val service: S) : Binder() diff --git a/app/src/main/java/com/nextcloud/client/core/LocalConnection.kt b/app/src/main/java/com/nextcloud/client/core/LocalConnection.kt new file mode 100644 index 000000000000..1d58f3c9dd84 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/LocalConnection.kt @@ -0,0 +1,89 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder + +/** + * This is a local service connection providing a foundation for service + * communication logic. + * + * One can subclass it to create own service interaction API. + */ +abstract class LocalConnection(protected val context: Context) : ServiceConnection { + + private var serviceBinder: LocalBinder? = null + val service: S? get() = serviceBinder?.service + val isConnected: Boolean get() { + return serviceBinder != null + } + + /** + * Override this method to create custom binding intent. + * Default implementation returns null, which disables binding. + * + * @see [bind] + */ + protected open fun createBindIntent(): Intent? = null + + /** + * Bind local service. If [createBindIntent] returns null, it no-ops. + */ + fun bind() { + createBindIntent()?.let { + context.bindService(it, this, Context.BIND_AUTO_CREATE) + } + } + + /** + * Unbind service if it is bound. + * If service is not bound, it no-ops. + */ + fun unbind() { + if (isConnected) { + onUnbind() + context.unbindService(this) + serviceBinder = null + } + } + + /** + * Callback called when connection binds to a service. + * Any actions taken on service connection can be taken here. + */ + protected open fun onBound(binder: IBinder) { + // default no-op + } + + /** + * Callback called when service is about to be unbound. + * Binder is still valid at this stage and can be used to + * perform cleanups. After exiting this method, service will + * no longer be available. + */ + protected open fun onUnbind() { + // default no-op + } + + final override fun onServiceConnected(name: ComponentName, binder: IBinder) { + if (binder !is LocalBinder<*>) { + throw IllegalStateException("Binder is not extending ${LocalBinder::class.java.name}") + } + @Suppress("UNCHECKED_CAST") // Safe: type S guaranteed by service binding contract + serviceBinder = binder as LocalBinder + onBound(binder) + } + + final override fun onServiceDisconnected(name: ComponentName) { + serviceBinder = null + } +} diff --git a/app/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt b/app/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt new file mode 100644 index 000000000000..d301eb11e46b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +import java.util.ArrayDeque + +/** + * This async runner is suitable for tests, where manual simulation of + * asynchronous operations is desirable. + */ +class ManualAsyncRunner : AsyncRunner { + + private val queue: ArrayDeque = ArrayDeque() + + override fun postQuickTask( + task: () -> T, + onResult: OnResultCallback?, + onError: OnErrorCallback? + ): Cancellable = postTask( + task = { _: OnProgressCallback, _: IsCancelled -> task.invoke() }, + onResult = onResult, + onError = onError, + onProgress = null + ) + + override fun postTask( + task: TaskFunction, + onResult: OnResultCallback?, + onError: OnErrorCallback?, + onProgress: OnProgressCallback

? + ): Cancellable { + val remove: Function1 = queue::remove + val taskWrapper = Task( + postResult = { + it.run() + true + }, + removeFromQueue = remove, + taskBody = task, + onSuccess = onResult, + onError = onError, + onProgress = onProgress + ) + queue.push(taskWrapper) + return taskWrapper + } + + val size: Int get() = queue.size + val isEmpty: Boolean get() = queue.size == 0 + + /** + * Run all enqueued tasks until queue is empty. This will run also tasks + * enqueued by task callbacks. + * + * @param maximum max number of tasks to run to avoid infinite loopss + * @return number of executed tasks + */ + fun runAll(maximum: Int = 100): Int { + var c = 0 + while (queue.size > 0) { + val t = queue.remove() + t.run() + c++ + if (c > maximum) { + throw IllegalStateException("Maximum number of tasks run. Are you in infinite loop?") + } + } + return c + } + + /** + * Run one pending task + * + * @return true if task has been run + */ + fun runOne(): Boolean { + val t = queue.pollFirst() + t?.run() + return t != null + } +} diff --git a/app/src/main/java/com/nextcloud/client/core/Task.kt b/app/src/main/java/com/nextcloud/client/core/Task.kt new file mode 100644 index 000000000000..6c04e3aa551f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/Task.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * This is a wrapper for a task function running in background. + * It executes task function and handles result or error delivery. + */ +@Suppress("LongParameterList") +internal class Task( + private val postResult: (Runnable) -> Boolean, + private val removeFromQueue: (Runnable) -> Boolean, + private val taskBody: TaskFunction, + private val onSuccess: OnResultCallback?, + private val onError: OnErrorCallback?, + private val onProgress: OnProgressCallback

? +) : Runnable, + Cancellable { + + val isCancelled: Boolean + get() = cancelled.get() + + private val cancelled = AtomicBoolean(false) + + private fun postProgress(p: P) { + postResult(Runnable { onProgress?.invoke(p) }) + } + + @Suppress("TooGenericExceptionCaught") // this is exactly what we want here + override fun run() { + try { + val result = taskBody.invoke({ postProgress(it) }, this::isCancelled) + if (!cancelled.get()) { + postResult.invoke( + Runnable { + onSuccess?.invoke(result) + } + ) + } + } catch (t: Throwable) { + if (!cancelled.get()) { + postResult(Runnable { onError?.invoke(t) }) + } + } + removeFromQueue(this) + } + + override fun cancel() { + cancelled.set(true) + removeFromQueue(this) + } +} diff --git a/app/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt b/app/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt new file mode 100644 index 000000000000..0c6da24ed343 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.core + +import android.os.Handler +import java.util.concurrent.ScheduledThreadPoolExecutor + +/** + * This async runner uses [java.util.concurrent.ScheduledThreadPoolExecutor] to run tasks + * asynchronously. + * + * Tasks are run on multi-threaded pool. If serialized execution is desired, set [corePoolSize] to 1. + */ +internal class ThreadPoolAsyncRunner( + private val uiThreadHandler: Handler, + corePoolSize: Int, + val tag: String = "default" +) : AsyncRunner { + + private val executor = ScheduledThreadPoolExecutor(corePoolSize) + + override fun postQuickTask( + task: () -> T, + onResult: OnResultCallback?, + onError: OnErrorCallback? + ): Cancellable { + val taskAdapter = { _: OnProgressCallback, _: IsCancelled -> task.invoke() } + return postTask( + taskAdapter, + onResult, + onError, + null + ) + } + + override fun postTask( + task: TaskFunction, + onResult: OnResultCallback?, + onError: OnErrorCallback?, + onProgress: OnProgressCallback

? + ): Cancellable { + val remove: Function1 = executor::remove + val taskWrapper = Task( + postResult = uiThreadHandler::post, + removeFromQueue = remove, + taskBody = task, + onSuccess = onResult, + onError = onError, + onProgress = onProgress + ) + executor.execute(taskWrapper) + return taskWrapper + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/DatabaseModule.kt b/app/src/main/java/com/nextcloud/client/database/DatabaseModule.kt new file mode 100644 index 000000000000..a0989a2c3bf5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/DatabaseModule.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database + +import android.content.Context +import com.nextcloud.client.core.Clock +import com.nextcloud.client.database.dao.ArbitraryDataDao +import com.nextcloud.client.database.dao.FileDao +import com.nextcloud.client.database.dao.OfflineOperationDao +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +class DatabaseModule { + + @Provides + @Singleton + fun database(context: Context, clock: Clock): NextcloudDatabase = NextcloudDatabase.getInstance(context, clock) + + @Provides + fun arbitraryDataDao(nextcloudDatabase: NextcloudDatabase): ArbitraryDataDao = nextcloudDatabase.arbitraryDataDao() + + @Provides + fun fileDao(nextcloudDatabase: NextcloudDatabase): FileDao = nextcloudDatabase.fileDao() + + @Provides + fun offlineOperationsDao(nextcloudDatabase: NextcloudDatabase): OfflineOperationDao = + nextcloudDatabase.offlineOperationDao() +} diff --git a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt new file mode 100644 index 000000000000..39fa5c7b33ae --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt @@ -0,0 +1,151 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database + +import android.content.Context +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.nextcloud.client.core.Clock +import com.nextcloud.client.core.ClockImpl +import com.nextcloud.client.database.dao.ArbitraryDataDao +import com.nextcloud.client.database.dao.AssistantDao +import com.nextcloud.client.database.dao.CapabilityDao +import com.nextcloud.client.database.dao.FileDao +import com.nextcloud.client.database.dao.FileSystemDao +import com.nextcloud.client.database.dao.OfflineOperationDao +import com.nextcloud.client.database.dao.RecommendedFileDao +import com.nextcloud.client.database.dao.ShareDao +import com.nextcloud.client.database.dao.SyncedFolderDao +import com.nextcloud.client.database.dao.UploadDao +import com.nextcloud.client.database.entity.ArbitraryDataEntity +import com.nextcloud.client.database.entity.AssistantEntity +import com.nextcloud.client.database.entity.CapabilityEntity +import com.nextcloud.client.database.entity.ExternalLinkEntity +import com.nextcloud.client.database.entity.FileEntity +import com.nextcloud.client.database.entity.FilesystemEntity +import com.nextcloud.client.database.entity.OfflineOperationEntity +import com.nextcloud.client.database.entity.RecommendedFileEntity +import com.nextcloud.client.database.entity.ShareEntity +import com.nextcloud.client.database.entity.SyncedFolderEntity +import com.nextcloud.client.database.entity.UploadEntity +import com.nextcloud.client.database.entity.VirtualEntity +import com.nextcloud.client.database.migrations.DatabaseMigrationUtil +import com.nextcloud.client.database.migrations.MIGRATION_88_89 +import com.nextcloud.client.database.migrations.MIGRATION_97_98 +import com.nextcloud.client.database.migrations.MIGRATION_99_100 +import com.nextcloud.client.database.migrations.Migration67to68 +import com.nextcloud.client.database.migrations.RoomMigration +import com.nextcloud.client.database.migrations.addLegacyMigrations +import com.nextcloud.client.database.typeConverter.OfflineOperationTypeConverter +import com.owncloud.android.MainApp +import com.owncloud.android.db.ProviderMeta + +@Database( + entities = [ + ArbitraryDataEntity::class, + CapabilityEntity::class, + ExternalLinkEntity::class, + FileEntity::class, + FilesystemEntity::class, + ShareEntity::class, + SyncedFolderEntity::class, + UploadEntity::class, + VirtualEntity::class, + OfflineOperationEntity::class, + RecommendedFileEntity::class, + AssistantEntity::class + ], + version = ProviderMeta.DB_VERSION, + autoMigrations = [ + AutoMigration(from = 65, to = 66), + AutoMigration(from = 66, to = 67), + AutoMigration(from = 68, to = 69), + AutoMigration(from = 69, to = 70), + AutoMigration(from = 70, to = 71, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 71, to = 72), + AutoMigration(from = 72, to = 73), + AutoMigration(from = 73, to = 74, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 74, to = 75), + AutoMigration(from = 75, to = 76), + AutoMigration(from = 76, to = 77), + AutoMigration(from = 77, to = 78), + AutoMigration(from = 78, to = 79, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 79, to = 80), + AutoMigration(from = 80, to = 81), + AutoMigration(from = 81, to = 82), + AutoMigration(from = 82, to = 83), + AutoMigration(from = 83, to = 84), + AutoMigration(from = 84, to = 85, spec = DatabaseMigrationUtil.DeleteColumnSpec::class), + AutoMigration(from = 85, to = 86, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 86, to = 87, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 87, to = 88, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + // manual migration used for 88 to 89 + AutoMigration(from = 89, to = 90), + AutoMigration(from = 90, to = 91), + AutoMigration(from = 91, to = 92), + AutoMigration(from = 92, to = 93, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 93, to = 94, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 94, to = 95, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 95, to = 96), + AutoMigration(from = 96, to = 97, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + // manual migration used for 97 to 98 + AutoMigration(from = 98, to = 99) + // manual migration used for 99 to 100 + ], + exportSchema = true +) +@Suppress("Detekt.UnnecessaryAbstractClass") // needed by Room +@TypeConverters(OfflineOperationTypeConverter::class) +abstract class NextcloudDatabase : RoomDatabase() { + + abstract fun arbitraryDataDao(): ArbitraryDataDao + abstract fun fileDao(): FileDao + abstract fun offlineOperationDao(): OfflineOperationDao + abstract fun uploadDao(): UploadDao + abstract fun recommendedFileDao(): RecommendedFileDao + abstract fun fileSystemDao(): FileSystemDao + abstract fun syncedFolderDao(): SyncedFolderDao + abstract fun assistantDao(): AssistantDao + abstract fun shareDao(): ShareDao + abstract fun capabilityDao(): CapabilityDao + + companion object { + const val FIRST_ROOM_DB_VERSION = 65 + private var instance: NextcloudDatabase? = null + + @JvmStatic + @Suppress("DeprecatedCallableAddReplaceWith") + @Deprecated("Here for legacy purposes, inject this class or use getInstance(context, clock) instead") + fun getInstance(context: Context): NextcloudDatabase = getInstance(context, ClockImpl()) + + @JvmStatic + fun getInstance(context: Context, clock: Clock): NextcloudDatabase { + if (instance == null) { + instance = Room + .databaseBuilder(context, NextcloudDatabase::class.java, ProviderMeta.DB_NAME) + .allowMainThreadQueries() + .addTypeConverter(OfflineOperationTypeConverter()) + .addLegacyMigrations(clock, context) + .addMigrations(RoomMigration()) + .addMigrations(Migration67to68()) + .addMigrations(MIGRATION_88_89) + .addMigrations(MIGRATION_97_98) + .addMigrations(MIGRATION_99_100) + .build() + } + return instance!! + } + + @Suppress("DEPRECATION") + @JvmStatic + fun instance(): NextcloudDatabase = getInstance(MainApp.getAppContext()) + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/ArbitraryDataDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/ArbitraryDataDao.kt new file mode 100644 index 000000000000..507e886457ce --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/ArbitraryDataDao.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Query +import com.nextcloud.client.database.entity.ArbitraryDataEntity + +@Dao +interface ArbitraryDataDao { + @Query("INSERT INTO arbitrary_data(cloud_id, `key`, value) VALUES(:accountName, :key, :value)") + fun insertValue(accountName: String, key: String, value: String?) + + @Query("SELECT * FROM arbitrary_data WHERE cloud_id = :accountName AND `key` = :key LIMIT 1") + fun getByAccountAndKey(accountName: String, key: String): ArbitraryDataEntity? + + @Query("UPDATE arbitrary_data SET value = :value WHERE cloud_id = :accountName AND `key` = :key ") + fun updateValue(accountName: String, key: String, value: String?) + + @Query("DELETE FROM arbitrary_data WHERE cloud_id = :accountName AND `key` = :key") + fun deleteValue(accountName: String, key: String) +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/AssistantDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/AssistantDao.kt new file mode 100644 index 000000000000..f674ae4f3c6c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/AssistantDao.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.nextcloud.client.database.entity.AssistantEntity +import com.owncloud.android.db.ProviderMeta + +@Dao +interface AssistantDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAssistantTask(task: AssistantEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAssistantTasks(tasks: List) + + @Update + suspend fun updateAssistantTask(task: AssistantEntity) + + @Delete + suspend fun deleteAssistantTask(task: AssistantEntity) + + @Query( + """ + SELECT * FROM ${ProviderMeta.ProviderTableMeta.ASSISTANT_TABLE_NAME} + WHERE accountName = :accountName AND type = :taskType + ORDER BY lastUpdated DESC +""" + ) + suspend fun getAssistantTasksByAccount(accountName: String, taskType: String): List +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/CapabilityDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/CapabilityDao.kt new file mode 100644 index 000000000000..51ea739b2fe3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/CapabilityDao.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Query +import com.nextcloud.client.database.entity.CapabilityEntity + +@Dao +interface CapabilityDao { + + @Query("SELECT * FROM capabilities WHERE account = :accountName LIMIT 1") + suspend fun getByAccountName(accountName: String): CapabilityEntity? +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt new file mode 100644 index 000000000000..7f15f5ec3252 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -0,0 +1,165 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Dariusz Olszewski + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Update +import com.nextcloud.client.database.entity.FileEntity +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.utils.MimeType + +@Suppress("TooManyFunctions") +@Dao +interface FileDao { + @Update + fun update(entity: FileEntity) + + @Query("SELECT * FROM filelist WHERE _id = :id LIMIT 1") + fun getFileById(id: Long): FileEntity? + + @Query("SELECT * FROM filelist WHERE local_id = :localId LIMIT 1") + fun getFileByLocalId(localId: Long): FileEntity? + + @Query("SELECT * FROM filelist WHERE path = :path AND file_owner = :fileOwner LIMIT 1") + fun getFileByEncryptedRemotePath(path: String, fileOwner: String): FileEntity? + + @Query("SELECT * FROM filelist WHERE path_decrypted = :path AND file_owner = :fileOwner LIMIT 1") + fun getFileByDecryptedRemotePath(path: String, fileOwner: String): FileEntity? + + @Query("SELECT * FROM filelist WHERE media_path = :path AND file_owner = :fileOwner LIMIT 1") + fun getFileByLocalPath(path: String, fileOwner: String): FileEntity? + + @Query("SELECT * FROM filelist WHERE remote_id = :remoteId AND file_owner = :fileOwner LIMIT 1") + fun getFileByRemoteId(remoteId: String, fileOwner: String): FileEntity? + + @Query("SELECT * FROM filelist WHERE remote_id = :remoteId LIMIT 1") + suspend fun getFileByRemoteId(remoteId: String): FileEntity? + + @Query("SELECT * FROM filelist WHERE parent = :parentId ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}") + fun getFolderContent(parentId: Long): List + + @Query("SELECT * FROM filelist WHERE parent = :parentId ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}") + suspend fun getFolderContentSuspended(parentId: Long): List + + @Query( + "SELECT * FROM filelist WHERE modified >= :startDate" + + " AND modified < :endDate" + + " AND (content_type LIKE 'image/%' OR content_type LIKE 'video/%')" + + " AND file_owner = :fileOwner" + + " ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}" + ) + fun getGalleryItems(startDate: Long, endDate: Long, fileOwner: String): List + + @Query("SELECT * FROM filelist WHERE file_owner = :fileOwner ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}") + fun getAllFiles(fileOwner: String): List + + @Query("SELECT * FROM filelist WHERE path LIKE :pathPattern AND file_owner = :fileOwner ORDER BY path ASC") + fun getFolderWithDescendants(pathPattern: String, fileOwner: String): List + + @Query("SELECT * FROM filelist where file_owner = :fileOwner AND etag_in_conflict IS NOT NULL") + fun getFilesWithSyncConflict(fileOwner: String): List + + @Query( + "SELECT * FROM filelist where file_owner = :fileOwner AND internal_two_way_sync_timestamp >= 0 " + + "ORDER BY internal_two_way_sync_timestamp DESC" + ) + fun getInternalTwoWaySyncFolders(fileOwner: String): List + + @Query( + """ + SELECT * + FROM filelist + WHERE parent = :parentId + AND file_owner = :accountName + AND is_encrypted = 0 + AND (content_type = :dirType OR content_type = :webdavType) + ORDER BY ${ProviderTableMeta._ID} ASC + """ + ) + fun getNonEncryptedSubfolders( + parentId: Long, + accountName: String, + dirType: String = MimeType.DIRECTORY, + webdavType: String = MimeType.WEBDAV_FOLDER + ): List + + @Query( + """ + SELECT NOT EXISTS ( + SELECT 1 + FROM filelist + WHERE parent = :parentId + AND file_owner = :accountName + AND content_type IS NOT NULL + AND content_type != '${MimeType.DIRECTORY}' + AND content_type != '${MimeType.WEBDAV_FOLDER}' + AND (media_path IS NULL OR TRIM(media_path) = '') + ) +""" + ) + fun areAllFilesHaveMediaPath(parentId: Long, accountName: String): Boolean + + @Query( + """ + SELECT * + FROM filelist + WHERE file_owner = :fileOwner + AND parent = :parentId + AND ${ProviderTableMeta.FILE_NAME} LIKE '%' || :query || '%' + ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER} + """ + ) + fun searchFilesInFolder(parentId: Long, fileOwner: String, query: String): List + + @Query( + """ + SELECT * + FROM filelist + WHERE file_owner = :accountName + AND ( + share_by_link = 1 + OR shared_via_users = 1 + OR permissions LIKE '%S%' + ) + ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER} + """ + ) + suspend fun getSharedFiles(accountName: String): List + + @Query( + """ + SELECT * + FROM filelist + WHERE file_owner = :fileOwner + AND favorite = 1 + ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER} + """ + ) + suspend fun getFavoriteFiles(fileOwner: String): List + + @Query("SELECT remote_id FROM filelist WHERE file_owner = :accountName AND remote_id IS NOT NULL") + fun getAllRemoteIds(accountName: String): List + + @Query( + """ + WITH RECURSIVE descendants AS ( + SELECT _id FROM filelist WHERE _id = :folderId AND file_owner = :fileOwner + UNION ALL + SELECT f._id FROM filelist f + INNER JOIN descendants d ON f.parent = d._id + WHERE f.file_owner = :fileOwner + ) + DELETE FROM filelist WHERE _id IN (SELECT _id FROM descendants) +""" + ) + fun deleteFolderWithDescendants(fileOwner: String, folderId: Long): Int + + @Query("DELETE FROM filelist WHERE file_owner = :fileOwner AND path = :remotePath") + fun deleteFileByRemotePath(fileOwner: String, remotePath: String): Int +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt new file mode 100644 index 000000000000..068b819e8c4b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt @@ -0,0 +1,110 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.nextcloud.client.database.entity.FilesystemEntity +import com.owncloud.android.db.ProviderMeta + +@Dao +interface FileSystemDao { + @Query( + """ + UPDATE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + SET ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_REMOTE_PATH} = :remotePath + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH} = :localPath + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId + """ + ) + suspend fun updateRemotePath(remotePath: String, localPath: String, syncedFolderId: String) + + @Query( + """ + SELECT * + FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId + """ + ) + suspend fun getBySyncedFolderId(syncedFolderId: String): List + + @Query( + """ + SELECT COUNT(*) > 0 FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH} = :localPath + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} IS NOT NULL + LIMIT 1 +""" + ) + suspend fun isBelongToAnyAutoFolder(localPath: String): Boolean + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(filesystemEntity: FilesystemEntity) + + @Delete + fun delete(entity: FilesystemEntity) + + @Query( + """ + DELETE FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH} = :localPath + AND ${ProviderMeta.ProviderTableMeta._ID} = :id + """ + ) + suspend fun deleteByLocalPathAndId(localPath: String, id: Int) + + @Query( + """ + SELECT * + FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD} = 0 + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER} = 0 + AND ${ProviderMeta.ProviderTableMeta._ID} > :lastId + ORDER BY ${ProviderMeta.ProviderTableMeta._ID} + LIMIT :limit + """ + ) + suspend fun getAutoUploadFilesEntities(syncedFolderId: String, limit: Int, lastId: Int): List + + @Query( + """ + UPDATE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + SET ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD} = 1 + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH} = :localPath + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId + """ + ) + suspend fun markFileAsUploaded(localPath: String, syncedFolderId: String) + + @Query( + """ + SELECT * + FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH} = :localPath + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId + LIMIT 1 + """ + ) + fun getFileByPathAndFolder(localPath: String, syncedFolderId: String): FilesystemEntity? + + @Query( + """ + SELECT COUNT(*) > 0 + FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD} = 0 + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER} = 0 + LIMIT 1 + """ + ) + suspend fun hasPendingFiles(syncedFolderId: String): Boolean +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/OfflineOperationDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/OfflineOperationDao.kt new file mode 100644 index 000000000000..50817daf28ee --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/OfflineOperationDao.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.nextcloud.client.database.entity.OfflineOperationEntity + +@Dao +interface OfflineOperationDao { + @Query("SELECT * FROM offline_operations") + fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg entity: OfflineOperationEntity) + + @Update + fun update(entity: OfflineOperationEntity) + + @Delete + fun delete(entity: OfflineOperationEntity) + + @Query("DELETE FROM offline_operations WHERE offline_operations_path = :path") + fun deleteByPath(path: String) + + @Query("SELECT * FROM offline_operations WHERE offline_operations_path = :path LIMIT 1") + fun getByPath(path: String): OfflineOperationEntity? + + @Query("SELECT * FROM offline_operations WHERE offline_operations_parent_oc_file_id = :parentOCFileId") + fun getSubEntitiesByParentOCFileId(parentOCFileId: Long): List + + @Query("DELETE FROM offline_operations") + fun clearTable() + + @Query("DELETE FROM offline_operations WHERE _id = :id") + fun deleteById(id: Int) +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/RecommendedFileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/RecommendedFileDao.kt new file mode 100644 index 000000000000..a93f857dac8d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/RecommendedFileDao.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.nextcloud.client.database.entity.RecommendedFileEntity +import com.owncloud.android.db.ProviderMeta + +@Dao +interface RecommendedFileDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(recommendedFiles: List) + + @Query( + "SELECT * FROM ${ProviderMeta.ProviderTableMeta.RECOMMENDED_FILE_TABLE_NAME} WHERE account_name = :accountName" + ) + suspend fun getAll(accountName: String): List +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/ShareDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/ShareDao.kt new file mode 100644 index 000000000000..61ea4a09c54a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/ShareDao.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.nextcloud.client.database.entity.ShareEntity + +@Dao +interface ShareDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(shares: List) + + @Query("DELETE FROM ocshares WHERE owner_share = :accountName") + suspend fun clearSharesForAccount(accountName: String) +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/SyncedFolderDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/SyncedFolderDao.kt new file mode 100644 index 000000000000..fb07cbeca955 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/SyncedFolderDao.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Query +import com.nextcloud.client.database.entity.SyncedFolderEntity +import com.owncloud.android.db.ProviderMeta +import kotlinx.coroutines.flow.Flow + +@Dao +interface SyncedFolderDao { + @Query( + """ + SELECT * FROM ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_LOCAL_PATH} = :localPath + AND ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ACCOUNT} = :account + LIMIT 1 + """ + ) + fun findByLocalPathAndAccount(localPath: String, account: String): SyncedFolderEntity? + + @Query("SELECT * FROM ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME}") + fun getAllAsFlow(): Flow> + + @Query( + """ + SELECT * FROM ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_REMOTE_PATH} = :remotePath + AND ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ACCOUNT} = :account + LIMIT 1 +""" + ) + suspend fun findByRemotePathAndAccount(remotePath: String, account: String): SyncedFolderEntity? +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt new file mode 100644 index 000000000000..07db239a7dd6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt @@ -0,0 +1,115 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.nextcloud.client.database.entity.UploadEntity +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Dao +interface UploadDao { + @Query( + "SELECT _id FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME + + " WHERE " + ProviderTableMeta.UPLOADS_STATUS + " = :status AND " + + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName AND _id IS NOT NULL" + ) + fun getAllIds(status: Int, accountName: String): List + + @Query( + "SELECT * FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME + + " WHERE " + ProviderTableMeta._ID + " IN (:ids) AND " + + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName" + ) + fun getUploadsByIds(ids: LongArray, accountName: String): List + + @Query( + "SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " + + "WHERE ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath LIMIT 1" + ) + fun getByRemotePath(remotePath: String): UploadEntity? + + @Query( + "DELETE FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " + + "WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName " + + "AND ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath" + ) + fun deleteByRemotePathAndAccountName(remotePath: String, accountName: String) + + @Query( + """ + DELETE FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} + WHERE ${ProviderTableMeta.UPLOADS_LOCAL_PATH} = :localPath + AND ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath +""" + ) + suspend fun deleteByLocalRemotePath(localPath: String, remotePath: String) + + @Query( + "SELECT * FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME + + " WHERE " + ProviderTableMeta._ID + " = :id AND " + + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName " + + "LIMIT 1" + ) + fun getUploadById(id: Long, accountName: String): UploadEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(entity: UploadEntity): Long + + @Query( + "SELECT * FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME + + " WHERE " + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName AND " + + ProviderTableMeta.UPLOADS_LOCAL_PATH + " = :localPath AND " + + ProviderTableMeta.UPLOADS_REMOTE_PATH + " = :remotePath " + + "LIMIT 1" + ) + fun getUploadByAccountAndPaths(accountName: String, localPath: String, remotePath: String): UploadEntity? + + @Query( + "UPDATE ${ProviderTableMeta.UPLOADS_TABLE_NAME} " + + "SET ${ProviderTableMeta.UPLOADS_STATUS} = :status " + + "WHERE ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath " + + "AND ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName" + ) + suspend fun updateStatus(remotePath: String, accountName: String, status: Int): Int + + @Query( + """ + UPDATE ${ProviderTableMeta.UPLOADS_TABLE_NAME} + SET ${ProviderTableMeta.UPLOADS_STATUS} = :status + WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName + AND ${ProviderTableMeta.UPLOADS_REMOTE_PATH} IN (:remotePaths) +""" + ) + suspend fun updateStatuses(remotePaths: List, accountName: String, status: Int): Int + + @Query( + """ + SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} + WHERE ${ProviderTableMeta.UPLOADS_STATUS} = :status + AND (:nameCollisionPolicy IS NULL OR ${ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY} = :nameCollisionPolicy) +""" + ) + suspend fun getUploadsByStatus(status: Int, nameCollisionPolicy: Int? = null): List + + @Query( + """ + SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} + WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName + AND ${ProviderTableMeta.UPLOADS_STATUS} = :status + AND (:nameCollisionPolicy IS NULL OR ${ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY} = :nameCollisionPolicy) +""" + ) + suspend fun getUploadsByAccountNameAndStatus( + accountName: String, + status: Int, + nameCollisionPolicy: Int? = null + ): List +} diff --git a/app/src/main/java/com/nextcloud/client/database/entity/ArbitraryDataEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/ArbitraryDataEntity.kt new file mode 100644 index 000000000000..d996521e4a4a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/ArbitraryDataEntity.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.ARBITRARY_DATA_TABLE_NAME) +data class ArbitraryDataEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int?, + @ColumnInfo(name = ProviderTableMeta.ARBITRARY_DATA_CLOUD_ID) + val cloudId: String?, + @ColumnInfo(name = ProviderTableMeta.ARBITRARY_DATA_KEY) + val key: String?, + @ColumnInfo(name = ProviderTableMeta.ARBITRARY_DATA_VALUE) + val value: String? +) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/AssistantEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/AssistantEntity.kt new file mode 100644 index 000000000000..4c13c25b340c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/AssistantEntity.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.owncloud.android.db.ProviderMeta + +@Entity(tableName = ProviderMeta.ProviderTableMeta.ASSISTANT_TABLE_NAME) +data class AssistantEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0L, + val accountName: String?, + val type: String?, + val status: String?, + val userId: String?, + val appId: String?, + val input: String? = null, + val output: String? = null, + val completionExpectedAt: Int? = null, + var progress: Int? = null, + val lastUpdated: Int? = null, + val scheduledAt: Int? = null, + val endedAt: Int? = null +) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt new file mode 100644 index 000000000000..eb225bab7972 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt @@ -0,0 +1,237 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.E2EVersion +import com.owncloud.android.lib.resources.status.OCCapability + +@Entity(tableName = ProviderTableMeta.CAPABILITIES_TABLE_NAME) +data class CapabilityEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_ASSISTANT) + val assistant: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_ACCOUNT_NAME) + val accountName: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_VERSION_MAYOR) + val versionMajor: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_VERSION_MINOR) + val versionMinor: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_VERSION_MICRO) + val versionMicro: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_VERSION_STRING) + val versionString: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_VERSION_EDITION) + val versionEditor: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_EXTENDED_SUPPORT) + val extendedSupport: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_CORE_POLLINTERVAL) + val corePollinterval: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_API_ENABLED) + val sharingApiEnabled: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_ENABLED) + val sharingPublicEnabled: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED) + val sharingPublicPasswordEnforced: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENABLED) + val sharingPublicExpireDateEnabled: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_DAYS) + val sharingPublicExpireDateDays: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENFORCED) + val sharingPublicExpireDateEnforced: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_SEND_MAIL) + val sharingPublicSendMail: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_UPLOAD) + val sharingPublicUpload: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_USER_SEND_MAIL) + val sharingUserSendMail: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_RESHARING) + val sharingResharing: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_FEDERATION_OUTGOING) + val sharingFederationOutgoing: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_FEDERATION_INCOMING) + val sharingFederationIncoming: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FILES_BIGFILECHUNKING) + val filesBigfilechunking: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FILES_UNDELETE) + val filesUndelete: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FILES_VERSIONING) + val filesVersioning: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_EXTERNAL_LINKS) + val externalLinks: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_NAME) + val serverName: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_COLOR) + val serverColor: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_TEXT_COLOR) + val serverTextColor: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_ELEMENT_COLOR) + val serverElementColor: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_SLOGAN) + val serverSlogan: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_LOGO) + val serverLogo: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_URL) + val serverBackgroundUrl: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION) + val endToEndEncryption: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION_KEYS_EXIST) + val endToEndEncryptionKeysExist: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION_API_VERSION) + val endToEndEncryptionApiVersion: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_ACTIVITY) + val activity: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_DEFAULT) + val serverBackgroundDefault: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_PLAIN) + val serverBackgroundPlain: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_RICHDOCUMENT) + val richdocument: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_MIMETYPE_LIST) + val richdocumentMimetypeList: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_DIRECT_EDITING) + val richdocumentDirectEditing: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_TEMPLATES) + val richdocumentTemplates: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_OPTIONAL_MIMETYPE_LIST) + val richdocumentOptionalMimetypeList: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_ASK_FOR_OPTIONAL_PASSWORD) + val sharingPublicAskForOptionalPassword: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_PRODUCT_NAME) + val richdocumentProductName: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_DIRECT_EDITING_ETAG) + val directEditingEtag: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_USER_STATUS) + val userStatus: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_EMOJI) + val userStatusSupportsEmoji: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_ETAG) + val etag: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FILES_LOCKING_VERSION) + val filesLockingVersion: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_GROUPFOLDERS) + val groupfolders: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_DROP_ACCOUNT) + val dropAccount: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SECURITY_GUARD) + val securityGuard: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS) + val forbiddenFileNameCharacters: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES) + val forbiddenFileNames: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_EXTENSIONS) + val forbiddenFileNameExtensions: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_BASE_NAMES) + val forbiddenFilenameBaseNames: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT) + val filesDownloadLimit: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT_DEFAULT) + val filesDownloadLimitDefault: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_RECOMMENDATION) + val recommendation: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_NOTES_FOLDER_PATH) + val notesFolderPath: String?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS) + val defaultPermissions: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_BUSY) + val userStatusSupportsBusy: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES) + val isWCFEnabled: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION) + val hasValidSubscription: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_CLIENT_INTEGRATION_JSON) + val clientIntegrationJson: String? +) + +@Suppress("LongMethod", "ReturnCount") +fun CapabilityEntity?.toOCCapability(): OCCapability { + val capability = OCCapability() + if (this == null) return capability + val id = this.id ?: return capability + + fun intToBoolean(value: Int?): CapabilityBooleanType = + value?.let { CapabilityBooleanType.fromValue(it) } ?: CapabilityBooleanType.UNKNOWN + + capability.id = id.toLong() + capability.accountName = this.accountName + capability.versionMayor = this.versionMajor ?: 0 + capability.versionMinor = this.versionMinor ?: 0 + capability.versionMicro = this.versionMicro ?: 0 + capability.versionString = this.versionString + capability.versionEdition = this.versionEditor + capability.extendedSupport = intToBoolean(this.extendedSupport) + capability.corePollInterval = this.corePollinterval ?: 0 + capability.filesSharingApiEnabled = intToBoolean(this.sharingApiEnabled) + capability.filesSharingPublicEnabled = intToBoolean(this.sharingPublicEnabled) + capability.filesSharingPublicPasswordEnforced = intToBoolean(this.sharingPublicPasswordEnforced) + capability.filesSharingPublicAskForOptionalPassword = intToBoolean(this.sharingPublicAskForOptionalPassword) + capability.filesSharingPublicExpireDateEnabled = intToBoolean(this.sharingPublicExpireDateEnabled) + capability.filesSharingPublicExpireDateDays = this.sharingPublicExpireDateDays ?: 0 + capability.filesSharingPublicExpireDateEnforced = intToBoolean(this.sharingPublicExpireDateEnforced) + capability.filesSharingPublicSendMail = intToBoolean(this.sharingPublicSendMail) + capability.filesSharingPublicUpload = intToBoolean(this.sharingPublicUpload) + capability.filesSharingUserSendMail = intToBoolean(this.sharingUserSendMail) + capability.filesSharingResharing = intToBoolean(this.sharingResharing) + capability.filesSharingFederationOutgoing = intToBoolean(this.sharingFederationOutgoing) + capability.filesSharingFederationIncoming = intToBoolean(this.sharingFederationIncoming) + capability.filesBigFileChunking = intToBoolean(this.filesBigfilechunking) + capability.filesUndelete = intToBoolean(this.filesUndelete) + capability.filesVersioning = intToBoolean(this.filesVersioning) + capability.externalLinks = intToBoolean(this.externalLinks) + capability.serverName = this.serverName + capability.serverColor = this.serverColor + capability.serverTextColor = this.serverTextColor + capability.serverElementColor = this.serverElementColor + capability.serverSlogan = this.serverSlogan + capability.serverLogo = this.serverLogo + capability.serverBackground = this.serverBackgroundUrl + capability.endToEndEncryption = intToBoolean(this.endToEndEncryption) + capability.endToEndEncryptionKeysExist = intToBoolean(this.endToEndEncryptionKeysExist) + capability.endToEndEncryptionApiVersion = this.endToEndEncryptionApiVersion?.let { + E2EVersion.fromValue(it) + } ?: E2EVersion.UNKNOWN + capability.serverBackgroundDefault = intToBoolean(this.serverBackgroundDefault) + capability.serverBackgroundPlain = intToBoolean(this.serverBackgroundPlain) + capability.activity = intToBoolean(this.activity) + capability.richDocuments = intToBoolean(this.richdocument) + capability.richDocumentsDirectEditing = intToBoolean(this.richdocumentDirectEditing) + capability.richDocumentsTemplatesAvailable = intToBoolean(this.richdocumentTemplates) + capability.richDocumentsMimeTypeList = this.richdocumentMimetypeList?.split(",") ?: emptyList() + capability.richDocumentsOptionalMimeTypeList = this.richdocumentOptionalMimetypeList?.split(",") ?: emptyList() + capability.richDocumentsProductName = this.richdocumentProductName + capability.directEditingEtag = this.directEditingEtag + capability.etag = this.etag + capability.userStatus = intToBoolean(this.userStatus) + capability.userStatusSupportsEmoji = intToBoolean(this.userStatusSupportsEmoji) + capability.userStatusSupportsBusy = intToBoolean(this.userStatusSupportsBusy) + capability.filesLockingVersion = this.filesLockingVersion + capability.assistant = intToBoolean(this.assistant) + capability.groupfolders = intToBoolean(this.groupfolders) + capability.dropAccount = intToBoolean(this.dropAccount) + capability.securityGuard = intToBoolean(this.securityGuard) + capability.forbiddenFilenameCharactersJson = this.forbiddenFileNameCharacters + capability.forbiddenFilenamesJson = this.forbiddenFileNames + capability.forbiddenFilenameExtensionJson = this.forbiddenFileNameExtensions + capability.forbiddenFilenameBaseNamesJson = this.forbiddenFilenameBaseNames + capability.isWCFEnabled = intToBoolean(this.isWCFEnabled) + capability.filesDownloadLimit = intToBoolean(this.filesDownloadLimit) + capability.filesDownloadLimitDefault = this.filesDownloadLimitDefault ?: 0 + capability.recommendations = intToBoolean(this.recommendation) + capability.notesFolderPath = this.notesFolderPath + capability.defaultPermissions = this.defaultPermissions ?: 0 + capability.hasValidSubscription = intToBoolean(this.hasValidSubscription) + capability.clientIntegrationJson = this.clientIntegrationJson + + return capability +} diff --git a/app/src/main/java/com/nextcloud/client/database/entity/ExternalLinkEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/ExternalLinkEntity.kt new file mode 100644 index 000000000000..03da55552ade --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/ExternalLinkEntity.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.EXTERNAL_LINKS_TABLE_NAME) +data class ExternalLinkEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int?, + @ColumnInfo(name = ProviderTableMeta.EXTERNAL_LINKS_ICON_URL) + val iconUrl: String?, + @ColumnInfo(name = ProviderTableMeta.EXTERNAL_LINKS_LANGUAGE) + val language: String?, + @ColumnInfo(name = ProviderTableMeta.EXTERNAL_LINKS_TYPE) + val type: Int?, + @ColumnInfo(name = ProviderTableMeta.EXTERNAL_LINKS_NAME) + val name: String?, + @ColumnInfo(name = ProviderTableMeta.EXTERNAL_LINKS_URL) + val url: String?, + @ColumnInfo(name = ProviderTableMeta.EXTERNAL_LINKS_REDIRECT) + val redirect: Int? +) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt new file mode 100644 index 000000000000..175287ba738c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt @@ -0,0 +1,125 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.FILE_TABLE_NAME) +data class FileEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_NAME) + val name: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_ENCRYPTED_NAME) + val encryptedName: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_PATH) + val path: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_PATH_DECRYPTED) + val pathDecrypted: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_PARENT) + val parent: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_CREATION) + val creation: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_MODIFIED) + val modified: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_CONTENT_TYPE) + val contentType: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_CONTENT_LENGTH) + val contentLength: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_STORAGE_PATH) + val storagePath: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_ACCOUNT_OWNER) + val accountOwner: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_LAST_SYNC_DATE) + val lastSyncDate: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA) + val lastSyncDateForData: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA) + val modifiedAtLastSyncForData: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_ETAG) + val etag: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_ETAG_ON_SERVER) + val etagOnServer: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_SHARED_VIA_LINK) + var sharedViaLink: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_PERMISSIONS) + val permissions: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_REMOTE_ID) + val remoteId: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCAL_ID, defaultValue = "-1") + val localId: Long, + @ColumnInfo(name = ProviderTableMeta.FILE_UPDATE_THUMBNAIL) + val updateThumbnail: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_IS_DOWNLOADING) + val isDownloading: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_FAVORITE) + val favorite: Int?, + + @ColumnInfo(name = ProviderTableMeta.FILE_HIDDEN) + val hidden: Int?, + + @ColumnInfo(name = ProviderTableMeta.FILE_IS_ENCRYPTED) + val isEncrypted: Int?, + + @ColumnInfo(name = ProviderTableMeta.FILE_ETAG_IN_CONFLICT) + val etagInConflict: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_SHARED_WITH_SHAREE) + var sharedWithSharee: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_MOUNT_TYPE) + val mountType: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_HAS_PREVIEW) + val hasPreview: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_UNREAD_COMMENTS_COUNT) + val unreadCommentsCount: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_OWNER_ID) + val ownerId: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_OWNER_DISPLAY_NAME) + val ownerDisplayName: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_NOTE) + val note: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_SHAREES) + val sharees: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_RICH_WORKSPACE) + val richWorkspace: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_METADATA_SIZE) + val metadataSize: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_METADATA_LIVE_PHOTO) + val metadataLivePhoto: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCKED) + val locked: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCK_TYPE) + val lockType: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCK_OWNER) + val lockOwner: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCK_OWNER_DISPLAY_NAME) + val lockOwnerDisplayName: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCK_OWNER_EDITOR) + val lockOwnerEditor: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCK_TIMESTAMP) + val lockTimestamp: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCK_TIMEOUT) + val lockTimeout: Int?, + @ColumnInfo(name = ProviderTableMeta.FILE_LOCK_TOKEN) + val lockToken: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_TAGS) + val tags: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_METADATA_GPS) + val metadataGPS: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_E2E_COUNTER) + val e2eCounter: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_INTERNAL_TWO_WAY_SYNC_TIMESTAMP) + val internalTwoWaySync: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_INTERNAL_TWO_WAY_SYNC_RESULT) + val internalTwoWaySyncResult: String?, + @ColumnInfo(name = ProviderTableMeta.FILE_UPLOADED) + val uploaded: Long? +) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/FilesystemEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/FilesystemEntity.kt new file mode 100644 index 000000000000..031fc52baeb3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/FilesystemEntity.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.FILESYSTEM_TABLE_NAME) +data class FilesystemEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int?, + @ColumnInfo(name = ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH) + val localPath: String?, + @ColumnInfo(name = ProviderTableMeta.FILESYSTEM_FILE_REMOTE_PATH) + val remotePath: String?, + @ColumnInfo(name = ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER) + val fileIsFolder: Int?, + @ColumnInfo(name = ProviderTableMeta.FILESYSTEM_FILE_FOUND_RECENTLY) + val fileFoundRecently: Long?, + @ColumnInfo(name = ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD) + val fileSentForUpload: Int?, + @ColumnInfo(name = ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID) + val syncedFolderId: String?, + @ColumnInfo(name = ProviderTableMeta.FILESYSTEM_CRC32) + val crc32: String?, + @ColumnInfo(name = ProviderTableMeta.FILESYSTEM_FILE_MODIFIED) + val fileModified: Long? +) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/OfflineOperationEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/OfflineOperationEntity.kt new file mode 100644 index 000000000000..1d5b151922b8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/OfflineOperationEntity.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.entity + +import android.content.Context +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.nextcloud.model.OfflineOperationType +import com.owncloud.android.R +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.OFFLINE_OPERATION_TABLE_NAME) +data class OfflineOperationEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int? = null, + + @ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_PARENT_OC_FILE_ID) + var parentOCFileId: Long? = null, + + @ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_PATH) + var path: String? = null, + + @ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_TYPE) + var type: OfflineOperationType? = null, + + @ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_FILE_NAME) + var filename: String? = null, + + @ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_CREATED_AT) + var createdAt: Long? = null, + + @ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_MODIFIED_AT) + var modifiedAt: Long? = null +) { + fun isRenameOrRemove(): Boolean = + (type is OfflineOperationType.RenameFile || type is OfflineOperationType.RemoveFile) + + fun isCreate(): Boolean = (type is OfflineOperationType.CreateFile || type is OfflineOperationType.CreateFolder) + + fun getConflictText(context: Context): String { + val resId = when (type) { + is OfflineOperationType.RemoveFile -> { + R.string.offline_operations_worker_notification_remove_conflict_text + } + + is OfflineOperationType.RenameFile -> { + R.string.offline_operations_worker_notification_rename_conflict_text + } + + is OfflineOperationType.CreateFile -> { + R.string.offline_operations_worker_notification_create_file_conflict_text + } + + else -> { + R.string.offline_operations_worker_notification_create_folder_conflict_text + } + } + + return context.getString(resId, filename) + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/entity/RecommendedFileEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/RecommendedFileEntity.kt new file mode 100644 index 000000000000..650179700271 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/RecommendedFileEntity.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.nextcloud.android.lib.resources.recommendations.Recommendation +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.RECOMMENDED_FILE_TABLE_NAME) +data class RecommendedFileEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Long, + + @ColumnInfo(name = ProviderTableMeta.RECOMMENDED_FILE_NAME) + val name: String, + + @ColumnInfo(name = ProviderTableMeta.RECOMMENDED_FILE_DIRECTORY) + val directory: String, + + @ColumnInfo(name = ProviderTableMeta.RECOMMENDED_FILE_EXTENSIONS) + val extension: String, + + @ColumnInfo(name = ProviderTableMeta.RECOMMENDED_FILE_MIME_TYPE) + val mimeType: String, + + @ColumnInfo(name = ProviderTableMeta.RECOMMENDED_FILE_HAS_PREVIEW) + val hasPreview: Boolean, + + @ColumnInfo(name = ProviderTableMeta.RECOMMENDED_FILE_REASON) + val reason: String, + + @ColumnInfo(name = ProviderTableMeta.RECOMMENDED_TIMESTAMP) + val timestamp: Long, + + @ColumnInfo(name = ProviderTableMeta.RECOMMENDED_FILE_ACCOUNT_NAME) + val accountName: String? +) + +fun ArrayList.toEntity(accountName: String): List = this.map { recommendation -> + RecommendedFileEntity( + id = recommendation.id, + name = recommendation.name, + directory = recommendation.directory, + extension = recommendation.extension, + mimeType = recommendation.mimeType, + hasPreview = recommendation.hasPreview, + reason = recommendation.reason, + timestamp = recommendation.timestamp, + accountName = accountName + ) +} + +fun List.toOCFile(storageManager: FileDataStorageManager): ArrayList = + mapNotNull { entity -> + entity.id.let { + storageManager.getFileByLocalId(it).apply { + this?.reason = entity.reason + this?.setIsRecommendedFile(true) + } + } + } + .toCollection(ArrayList()) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/ShareEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/ShareEntity.kt new file mode 100644 index 000000000000..ad5005efef20 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/ShareEntity.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.OCSHARES_TABLE_NAME) +data class ShareEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_FILE_SOURCE) + val fileSource: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_ITEM_SOURCE) + val itemSource: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_SHARE_TYPE) + val shareType: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_SHARE_WITH) + val shareWith: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_PATH) + val path: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_PERMISSIONS) + val permissions: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_SHARED_DATE) + val sharedDate: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_EXPIRATION_DATE) + val expirationDate: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_TOKEN) + val token: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_SHARE_WITH_DISPLAY_NAME) + val shareWithDisplayName: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_IS_DIRECTORY) + val isDirectory: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_USER_ID) + val userId: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_ID_REMOTE_SHARED) + val idRemoteShared: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_ACCOUNT_OWNER) + val accountOwner: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_IS_PASSWORD_PROTECTED) + val isPasswordProtected: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_NOTE) + val note: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_HIDE_DOWNLOAD) + val hideDownload: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_SHARE_LINK) + val shareLink: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_SHARE_LABEL) + val shareLabel: String?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT) + val downloadLimitLimit: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT) + val downloadLimitCount: Int?, + @ColumnInfo(name = ProviderTableMeta.OCSHARES_ATTRIBUTES) + val attributes: String? +) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt new file mode 100644 index 000000000000..18b0911209ed --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.nextcloud.client.preferences.SubFolderRule +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME) +data class SyncedFolderEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_LOCAL_PATH) + val localPath: String?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_REMOTE_PATH) + val remotePath: String?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_WIFI_ONLY) + val wifiOnly: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_CHARGING_ONLY) + val chargingOnly: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_EXISTING) + val existing: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ENABLED) + val enabled: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ENABLED_TIMESTAMP_MS) + val enabledTimestampMs: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_SUBFOLDER_BY_DATE) + val subfolderByDate: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ACCOUNT) + val account: String?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_UPLOAD_ACTION) + val uploadAction: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_NAME_COLLISION_POLICY) + val nameCollisionPolicy: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_TYPE) + val type: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_HIDDEN) + val hidden: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_SUBFOLDER_RULE) + val subFolderRule: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_EXCLUDE_HIDDEN) + val excludeHidden: Int?, + @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_LAST_SCAN_TIMESTAMP_MS) + val lastScanTimestampMs: Long? +) + +fun SyncedFolderEntity.toSyncedFolder(): SyncedFolder = SyncedFolder( + // id + (this.id ?: SyncedFolder.UNPERSISTED_ID).toLong(), + // localPath + this.localPath ?: "", + // remotePath + this.remotePath ?: "", + // wifiOnly + this.wifiOnly == 1, + // chargingOnly + this.chargingOnly == 1, + // existing + this.existing == 1, + // subfolderByDate + this.subfolderByDate == 1, + // account + this.account ?: "", + // uploadAction + this.uploadAction ?: 0, + // nameCollisionPolicy + this.nameCollisionPolicy ?: 0, + // enabled + this.enabled == 1, + // timestampMs + (this.enabledTimestampMs ?: SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS).toLong(), + // type + MediaFolderType.getById(this.type ?: MediaFolderType.CUSTOM.id), + // hidden + this.hidden == 1, + // subFolderRule + this.subFolderRule?.let { SubFolderRule.entries[it] }, + // excludeHidden + this.excludeHidden == 1, + // lastScanTimestampMs + this.lastScanTimestampMs ?: SyncedFolder.NOT_SCANNED_YET +) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt new file mode 100644 index 000000000000..ff4ff9e91c6f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt @@ -0,0 +1,134 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.nextcloud.utils.autoRename.AutoRename +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.db.UploadResult +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.OCCapability +import java.lang.IllegalArgumentException + +@Entity(tableName = ProviderTableMeta.UPLOADS_TABLE_NAME) +data class UploadEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_LOCAL_PATH) + val localPath: String?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_REMOTE_PATH) + val remotePath: String?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_ACCOUNT_NAME) + val accountName: String?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_FILE_SIZE) + val fileSize: Long?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_STATUS) + val status: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_LOCAL_BEHAVIOUR) + val localBehaviour: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_UPLOAD_TIME) + val uploadTime: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY) + val nameCollisionPolicy: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_IS_CREATE_REMOTE_FOLDER) + val isCreateRemoteFolder: Int?, + + // do not use integer value of upload end timestamp, instead use long version of it + @ColumnInfo(name = ProviderTableMeta.UPLOADS_UPLOAD_END_TIMESTAMP) + val uploadEndTimestamp: Int?, + + @ColumnInfo(name = ProviderTableMeta.UPLOADS_UPLOAD_END_TIMESTAMP_LONG) + val uploadEndTimestampLong: Long?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_LAST_RESULT) + val lastResult: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_IS_WHILE_CHARGING_ONLY) + val isWhileChargingOnly: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_IS_WIFI_ONLY) + val isWifiOnly: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_CREATED_BY) + val createdBy: Int?, + @ColumnInfo(name = ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN) + val folderUnlockToken: String? +) + +fun UploadEntity.toOCUpload(capability: OCCapability? = null): OCUpload? { + val localPath = localPath + var remotePath = remotePath + if (capability != null && remotePath != null) { + remotePath = AutoRename.rename(remotePath, capability) + } + val upload = try { + OCUpload(localPath, remotePath, accountName) + } catch (_: IllegalArgumentException) { + Log_OC.e("UploadEntity", "OCUpload conversion failed") + return null + } + + fileSize?.let { upload.fileSize = it } + id?.let { upload.uploadId = it.toLong() } + status?.let { upload.uploadStatus = UploadsStorageManager.UploadStatus.fromValue(it) } + localBehaviour?.let { upload.localAction = it } + nameCollisionPolicy?.let { upload.nameCollisionPolicy = NameCollisionPolicy.deserialize(it) } + isCreateRemoteFolder?.let { upload.isCreateRemoteFolder = it == 1 } + uploadEndTimestampLong?.let { upload.uploadEndTimestamp = it } + lastResult?.let { upload.lastResult = UploadResult.fromValue(it) } + createdBy?.let { upload.createdBy = it } + isWifiOnly?.let { upload.isUseWifiOnly = it == 1 } + isWhileChargingOnly?.let { upload.isWhileChargingOnly = it == 1 } + folderUnlockToken?.let { upload.folderUnlockToken = it } + + return upload +} + +fun OCUpload.toUploadEntity(): UploadEntity { + val id = if (uploadId == -1L) { + Log_OC.d( + "UploadEntity", + "UploadEntity: No existing ID provided (uploadId = -1). " + + "Will insert as NEW record and let Room auto-generate the primary key." + ) + + // needed for the insert new records to the db so that insert DAO function returns new generated id + null + } else { + Log_OC.d( + "UploadEntity", + "UploadEntity: Using existing ID ($uploadId). " + + "This will update/replace the existing database record." + ) + + uploadId + } + + return UploadEntity( + id = id?.toInt(), + localPath = localPath, + remotePath = remotePath, + accountName = accountName, + fileSize = fileSize, + status = uploadStatus?.value, + localBehaviour = localAction, + nameCollisionPolicy = nameCollisionPolicy?.serialize(), + isCreateRemoteFolder = if (isCreateRemoteFolder) 1 else 0, + uploadEndTimestamp = 0, + uploadEndTimestampLong = uploadEndTimestamp, + lastResult = lastResult?.value, + createdBy = createdBy, + isWifiOnly = if (isUseWifiOnly) 1 else 0, + isWhileChargingOnly = if (isWhileChargingOnly) 1 else 0, + folderUnlockToken = folderUnlockToken, + uploadTime = null + ) +} diff --git a/app/src/main/java/com/nextcloud/client/database/entity/VirtualEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/VirtualEntity.kt new file mode 100644 index 000000000000..d8c7efef97c6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/entity/VirtualEntity.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Entity(tableName = ProviderTableMeta.VIRTUAL_TABLE_NAME) +data class VirtualEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ProviderTableMeta._ID) + val id: Int?, + @ColumnInfo(name = ProviderTableMeta.VIRTUAL_TYPE) + val type: String?, + @ColumnInfo(name = ProviderTableMeta.VIRTUAL_OCFILE_ID) + val ocFileId: Int? +) diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/DatabaseMigrationUtil.kt b/app/src/main/java/com/nextcloud/client/database/migrations/DatabaseMigrationUtil.kt new file mode 100644 index 000000000000..b520b4737746 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/DatabaseMigrationUtil.kt @@ -0,0 +1,129 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.migrations + +import androidx.room.DeleteColumn +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase +import com.nextcloud.client.database.migrations.model.SQLiteColumnType + +object DatabaseMigrationUtil { + + const val TYPE_TEXT = "TEXT" + const val TYPE_INTEGER = "INTEGER" + const val TYPE_INTEGER_PRIMARY_KEY = "INTEGER PRIMARY KEY" + const val KEYWORD_NOT_NULL = "NOT NULL" + + fun addColumnIfNotExists( + db: SupportSQLiteDatabase, + tableName: String, + columnName: String, + columnType: SQLiteColumnType + ) { + val cursor = db.query("PRAGMA table_info($tableName)") + var columnExists = false + + while (cursor.moveToNext()) { + val nameIndex = cursor.getColumnIndex("name") + if (nameIndex != -1) { + val existingColumnName = cursor.getString(nameIndex) + if (existingColumnName == columnName) { + columnExists = true + break + } + } + } + cursor.close() + + if (!columnExists) { + db.execSQL("ALTER TABLE $tableName ADD COLUMN `$columnName` ${columnType.value}") + } + } + + /** + * Utility method to add or remove columns from a table + * + * See individual functions for more details + * + * @param newColumns Map of column names and types on the NEW table + * @param selectTransform a function that transforms the select statement. This can be used to change the values + * when copying, such as for removing nulls + */ + fun migrateTable( + database: SupportSQLiteDatabase, + tableName: String, + newColumns: Map, + selectTransform: ((String) -> String)? = null + ) { + require(newColumns.isNotEmpty()) + val newTableTempName = "${tableName}_new" + createNewTable(database, newTableTempName, newColumns) + copyData(database, tableName, newTableTempName, newColumns.keys, selectTransform) + replaceTable(database, tableName, newTableTempName) + } + + fun resetCapabilities(database: SupportSQLiteDatabase) { + database.execSQL("UPDATE capabilities SET etag = '' WHERE 1=1") + } + + /** + * Utility method to create a new table with the given columns + */ + private fun createNewTable(database: SupportSQLiteDatabase, newTableName: String, columns: Map) { + val columnsString = columns.entries.joinToString(",") { "${it.key} ${it.value}" } + database.execSQL("CREATE TABLE $newTableName ($columnsString)") + } + + /** + * Utility method to copy data from an old table to a new table. Only the columns in [columnNames] will be copied + * + * @param selectTransform a function that transforms the select statement. This can be used to change the values + * when copying, such as for removing nulls + */ + private fun copyData( + database: SupportSQLiteDatabase, + tableName: String, + newTableName: String, + columnNames: Iterable, + selectTransform: ((String) -> String)? = null + ) { + val selectColumnsString = columnNames.joinToString(",", transform = selectTransform) + val destColumnsString = columnNames.joinToString(",") + + database.execSQL( + "INSERT INTO $newTableName ($destColumnsString) " + + "SELECT $selectColumnsString FROM $tableName" + ) + } + + /** + * Utility method to replace an old table with a new one, essentially deleting the old one and renaming the new one + */ + private fun replaceTable(database: SupportSQLiteDatabase, tableName: String, newTableTempName: String) { + database.execSQL("DROP TABLE $tableName") + database.execSQL("ALTER TABLE $newTableTempName RENAME TO $tableName") + } + + /** + * Room AutoMigrationSpec to reset capabilities post migration. + */ + class ResetCapabilitiesPostMigration : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + resetCapabilities(db) + super.onPostMigrate(db) + } + } + + @DeleteColumn.Entries( + DeleteColumn( + tableName = "offline_operations", + columnName = "offline_operations_parent_path" + ) + ) + class DeleteColumnSpec : AutoMigrationSpec +} diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/LegacyMigration.kt b/app/src/main/java/com/nextcloud/client/database/migrations/LegacyMigration.kt new file mode 100644 index 000000000000..feb42659f256 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/LegacyMigration.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.migrations + +import android.content.Context +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.nextcloud.client.core.Clock +import com.nextcloud.client.database.NextcloudDatabase + +private const val MIN_SUPPORTED_DB_VERSION = 24 + +/** + * Migrations for DB versions before Room was introduced + */ +class LegacyMigration( + private val from: Int, + private val to: Int, + private val clock: Clock, + private val context: Context +) : Migration(from, to) { + + override fun migrate(db: SupportSQLiteDatabase) { + LegacyMigrationHelper(clock, context) + .tryUpgrade(db, from, to) + } +} + +/** + * Adds a legacy migration for all versions before Room was introduced + * + * This is needed because the [Migration] does not know which versions it's dealing with + */ +@Suppress("ForEachOnRange") +fun RoomDatabase.Builder.addLegacyMigrations( + clock: Clock, + context: Context +): RoomDatabase.Builder { + (MIN_SUPPORTED_DB_VERSION until NextcloudDatabase.FIRST_ROOM_DB_VERSION - 1) + .map { from -> LegacyMigration(from, from + 1, clock, context) } + .forEach { migration -> this.addMigrations(migration) } + return this +} diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/LegacyMigrationHelper.java b/app/src/main/java/com/nextcloud/client/database/migrations/LegacyMigrationHelper.java new file mode 100644 index 000000000000..4cbd78a6532a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/LegacyMigrationHelper.java @@ -0,0 +1,959 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.migrations; + +import android.app.ActivityManager; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; + +import com.nextcloud.client.core.Clock; +import com.owncloud.android.datamodel.SyncedFolder; +import com.owncloud.android.db.ProviderMeta; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.utils.Log_OC; + +import java.util.Locale; + +import androidx.sqlite.db.SupportSQLiteDatabase; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +public class LegacyMigrationHelper { + + private static final String TAG = LegacyMigrationHelper.class.getSimpleName(); + + public static final int ARBITRARY_DATA_TABLE_INTRODUCTION_VERSION = 20; + + + private static final String ALTER_TABLE = "ALTER TABLE "; + private static final String ADD_COLUMN = " ADD COLUMN "; + private static final String INTEGER = " INTEGER, "; + private static final String TEXT = " TEXT, "; + + private static final String UPGRADE_VERSION_MSG = "OUT of the ADD in onUpgrade; oldVersion == %d, newVersion == %d"; + + private final Clock clock; + private final Context context; + + public LegacyMigrationHelper(Clock clock, Context context) { + this.clock = clock; + this.context = context; + } + + public void tryUpgrade(SupportSQLiteDatabase db, int oldVersion, int newVersion) { + try { + upgrade(db, oldVersion, newVersion); + } catch (Throwable t) { + Log_OC.i(TAG, "Migration upgrade failed due to " + t); + clearStorage(); + } + } + + @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_BAD_PRACTICE") + private void clearStorage() { + context.getCacheDir().delete(); + ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)).clearApplicationUserData(); + } + + private void upgrade(SupportSQLiteDatabase db, int oldVersion, int newVersion) { + Log_OC.i(TAG, "Entering in onUpgrade"); + boolean upgraded = false; + + if (oldVersion < 25 && newVersion >= 25) { + Log_OC.i(TAG, "Entering in the #25 Adding encryption flag to file"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_IS_ENCRYPTED + " INTEGER "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_ENCRYPTED_NAME + " TEXT "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION + " INTEGER "); + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 26 && newVersion >= 26) { + Log_OC.i(TAG, "Entering in the #26 Adding text and element color to capabilities"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_TEXT_COLOR + " TEXT "); + + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_ELEMENT_COLOR + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 27 && newVersion >= 27) { + Log_OC.i(TAG, "Entering in the #27 Adding token to ocUpload"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.UPLOADS_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN + " TEXT "); + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 28 && newVersion >= 28) { + Log_OC.i(TAG, "Entering in the #28 Adding CRC32 column to filesystem table"); + db.beginTransaction(); + try { + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME, + ProviderMeta.ProviderTableMeta.FILESYSTEM_CRC32)) { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILESYSTEM_CRC32 + " TEXT "); + } + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 29 && newVersion >= 29) { + Log_OC.i(TAG, "Entering in the #29 Adding background default/plain to capabilities"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_DEFAULT + " INTEGER "); + + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_PLAIN + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 30 && newVersion >= 30) { + Log_OC.i(TAG, "Entering in the #30 Re-add 25, 26 if needed"); + db.beginTransaction(); + try { + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME, + ProviderMeta.ProviderTableMeta.FILE_IS_ENCRYPTED)) { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_IS_ENCRYPTED + " INTEGER "); + } + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME, + ProviderMeta.ProviderTableMeta.FILE_ENCRYPTED_NAME)) { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_ENCRYPTED_NAME + " TEXT "); + } + if (oldVersion > ARBITRARY_DATA_TABLE_INTRODUCTION_VERSION) { + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME, + ProviderMeta.ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION)) { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION + " INTEGER "); + } + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME, + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_TEXT_COLOR)) { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_TEXT_COLOR + " TEXT "); + } + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME, + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_ELEMENT_COLOR)) { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_ELEMENT_COLOR + " TEXT "); + } + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME, + ProviderMeta.ProviderTableMeta.FILESYSTEM_CRC32)) { + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILESYSTEM_CRC32 + " TEXT "); + } catch (SQLiteException e) { + Log_OC.d(TAG, "Known problem on adding same column twice when upgrading from 24->30"); + } + } + } + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 31 && newVersion >= 31) { + Log_OC.i(TAG, "Entering in the #31 add mount type"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_MOUNT_TYPE + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 32 && newVersion >= 32) { + Log_OC.i(TAG, "Entering in the #32 add ocshares.is_password_protected"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.OCSHARES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.OCSHARES_IS_PASSWORD_PROTECTED + " INTEGER "); // boolean + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 33 && newVersion >= 33) { + Log_OC.i(TAG, "Entering in the #3 Adding activity to capability"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_ACTIVITY + " INTEGER "); + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 34 && newVersion >= 34) { + Log_OC.i(TAG, "Entering in the #34 add redirect to external links"); + db.beginTransaction(); + try { + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_TABLE_NAME, + ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_REDIRECT)) { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_REDIRECT + " INTEGER "); // boolean + } + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 35 && newVersion >= 35) { + Log_OC.i(TAG, "Entering in the #35 add note to share table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.OCSHARES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.OCSHARES_NOTE + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 36 && newVersion >= 36) { + Log_OC.i(TAG, "Entering in the #36 add has-preview to file table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_HAS_PREVIEW + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 37 && newVersion >= 37) { + Log_OC.i(TAG, "Entering in the #37 add hide-download to share table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.OCSHARES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.OCSHARES_HIDE_DOWNLOAD + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 38 && newVersion >= 38) { + Log_OC.i(TAG, "Entering in the #38 add richdocuments"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_RICHDOCUMENT + " INTEGER "); // boolean + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_MIMETYPE_LIST + " TEXT "); // string + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 39 && newVersion >= 39) { + Log_OC.i(TAG, "Entering in the #39 add richdocuments direct editing"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_DIRECT_EDITING + " INTEGER "); // bool + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 40 && newVersion >= 40) { + Log_OC.i(TAG, "Entering in the #40 add unreadCommentsCount to file table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_UNREAD_COMMENTS_COUNT + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 41 && newVersion >= 41) { + Log_OC.i(TAG, "Entering in the #41 add eTagOnServer"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_ETAG_ON_SERVER + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 42 && newVersion >= 42) { + Log_OC.i(TAG, "Entering in the #42 add richDocuments templates"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_TEMPLATES + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 43 && newVersion >= 43) { + Log_OC.i(TAG, "Entering in the #43 add ownerId and owner display name to file table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_OWNER_ID + " TEXT "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_OWNER_DISPLAY_NAME + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 44 && newVersion >= 44) { + Log_OC.i(TAG, "Entering in the #44 add note to file table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_NOTE + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 45 && newVersion >= 45) { + Log_OC.i(TAG, "Entering in the #45 add sharees to file table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_SHAREES + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 46 && newVersion >= 46) { + Log_OC.i(TAG, "Entering in the #46 add optional mimetypes to capabilities table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_OPTIONAL_MIMETYPE_LIST + + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 47 && newVersion >= 47) { + Log_OC.i(TAG, "Entering in the #47 add askForPassword to capability table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_ASK_FOR_OPTIONAL_PASSWORD + + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 48 && newVersion >= 48) { + Log_OC.i(TAG, "Entering in the #48 add product name to capabilities table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_PRODUCT_NAME + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 49 && newVersion >= 49) { + Log_OC.i(TAG, "Entering in the #49 add extended support to capabilities table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_EXTENDED_SUPPORT + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 50 && newVersion >= 50) { + Log_OC.i(TAG, "Entering in the #50 add persistent enable date to synced_folders table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ENABLED_TIMESTAMP_MS + " INTEGER "); + + db.execSQL("UPDATE " + ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME + " SET " + + ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ENABLED_TIMESTAMP_MS + " = CASE " + + " WHEN enabled = 0 THEN " + SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS + " " + + " ELSE " + clock.getCurrentTime() + + " END "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 51 && newVersion >= 51) { + Log_OC.i(TAG, "Entering in the #51 add show/hide to folderSync table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_HIDDEN + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 52 && newVersion >= 52) { + Log_OC.i(TAG, "Entering in the #52 add etag for directEditing to capability"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_DIRECT_EDITING_ETAG + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 53 && newVersion >= 53) { + Log_OC.i(TAG, "Entering in the #53 add rich workspace to file table"); + db.beginTransaction(); + try { + if (!checkIfColumnExists(db, ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME, + ProviderMeta.ProviderTableMeta.FILE_RICH_WORKSPACE)) { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_RICH_WORKSPACE + " TEXT "); + } + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 54 && newVersion >= 54) { + Log_OC.i(TAG, "Entering in the #54 add synced.existing," + + " rename uploads.force_overwrite to uploads.name_collision_policy"); + db.beginTransaction(); + try { + // Add synced.existing + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_EXISTING + " INTEGER "); // boolean + + + // Rename uploads.force_overwrite to uploads.name_collision_policy + String tmpTableName = ProviderMeta.ProviderTableMeta.UPLOADS_TABLE_NAME + "_old"; + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.UPLOADS_TABLE_NAME + " RENAME TO " + tmpTableName); + createUploadsTable(db); + db.execSQL("INSERT INTO " + ProviderMeta.ProviderTableMeta.UPLOADS_TABLE_NAME + " (" + + ProviderMeta.ProviderTableMeta._ID + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_LOCAL_PATH + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_REMOTE_PATH + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_ACCOUNT_NAME + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_FILE_SIZE + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_STATUS + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_LOCAL_BEHAVIOUR + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_UPLOAD_TIME + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_IS_CREATE_REMOTE_FOLDER + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_UPLOAD_END_TIMESTAMP + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_LAST_RESULT + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_IS_WHILE_CHARGING_ONLY + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_IS_WIFI_ONLY + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_CREATED_BY + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN + + ") " + + " SELECT " + + ProviderMeta.ProviderTableMeta._ID + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_LOCAL_PATH + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_REMOTE_PATH + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_ACCOUNT_NAME + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_FILE_SIZE + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_STATUS + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_LOCAL_BEHAVIOUR + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_UPLOAD_TIME + ", " + + "force_overwrite" + ", " + // See FileUploader.NameCollisionPolicy + ProviderMeta.ProviderTableMeta.UPLOADS_IS_CREATE_REMOTE_FOLDER + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_UPLOAD_END_TIMESTAMP + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_LAST_RESULT + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_IS_WHILE_CHARGING_ONLY + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_IS_WIFI_ONLY + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_CREATED_BY + ", " + + ProviderMeta.ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN + + " FROM " + tmpTableName); + db.execSQL("DROP TABLE " + tmpTableName); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 55 && newVersion >= 55) { + Log_OC.i(TAG, "Entering in the #55 add synced.name_collision_policy."); + db.beginTransaction(); + try { + // Add synced.name_collision_policy + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_NAME_COLLISION_POLICY + " INTEGER "); // integer + + // make sure all existing folders set to FileUploader.NameCollisionPolicy.ASK_USER. + db.execSQL("UPDATE " + ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME + " SET " + + ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_NAME_COLLISION_POLICY + " = " + + NameCollisionPolicy.ASK_USER.serialize()); + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 56 && newVersion >= 56) { + Log_OC.i(TAG, "Entering in the #56 add decrypted remote path"); + db.beginTransaction(); + try { + // Add synced.name_collision_policy + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_PATH_DECRYPTED + " TEXT "); // strin + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 57 && newVersion >= 57) { + Log_OC.i(TAG, "Entering in the #57 add etag for capabilities"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_ETAG + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 58 && newVersion >= 58) { + Log_OC.i(TAG, "Entering in the #58 add public link to share table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.OCSHARES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.OCSHARES_SHARE_LINK + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 59 && newVersion >= 59) { + Log_OC.i(TAG, "Entering in the #59 add public label to share table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.OCSHARES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.OCSHARES_SHARE_LABEL + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 60 && newVersion >= 60) { + Log_OC.i(TAG, "Entering in the #60 add user status to capability table"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_USER_STATUS + " INTEGER "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_EMOJI + " INTEGER "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 61 && newVersion >= 61) { + Log_OC.i(TAG, "Entering in the #61 reset eTag to force capability refresh"); + db.beginTransaction(); + try { + db.execSQL("UPDATE capabilities SET etag = '' WHERE 1=1"); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 62 && newVersion >= 62) { + Log_OC.i(TAG, "Entering in the #62 add logo to capability"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_SERVER_LOGO + " TEXT "); + + // force refresh + db.execSQL("UPDATE capabilities SET etag = '' WHERE 1=1"); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (oldVersion < 63 && newVersion >= 63) { + Log_OC.i(TAG, "Adding file locking columns"); + db.beginTransaction(); + try { + // locking capabilities + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME + ADD_COLUMN + ProviderMeta.ProviderTableMeta.CAPABILITIES_FILES_LOCKING_VERSION + " TEXT "); + // force refresh + db.execSQL("UPDATE capabilities SET etag = '' WHERE 1=1"); + // locking properties + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_LOCKED + " INTEGER "); // boolean + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_LOCK_TYPE + " INTEGER "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_LOCK_OWNER + " TEXT "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_LOCK_OWNER_DISPLAY_NAME + " TEXT "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_LOCK_OWNER_EDITOR + " TEXT "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_LOCK_TIMESTAMP + " INTEGER "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_LOCK_TIMEOUT + " INTEGER "); + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_LOCK_TOKEN + " TEXT "); + db.execSQL("UPDATE " + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + " SET " + ProviderMeta.ProviderTableMeta.FILE_ETAG + " = '' WHERE 1=1"); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + + if (oldVersion < 64 && newVersion >= 64) { + Log_OC.i(TAG, "Entering in the #64 add metadata size to files"); + db.beginTransaction(); + try { + db.execSQL(ALTER_TABLE + ProviderMeta.ProviderTableMeta.FILE_TABLE_NAME + + ADD_COLUMN + ProviderMeta.ProviderTableMeta.FILE_METADATA_SIZE + " TEXT "); + + upgraded = true; + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + if (!upgraded) { + Log_OC.i(TAG, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion)); + } + } + + private void createUploadsTable(SupportSQLiteDatabase db) { + db.execSQL("CREATE TABLE " + ProviderMeta.ProviderTableMeta.UPLOADS_TABLE_NAME + "(" + + ProviderMeta.ProviderTableMeta._ID + " INTEGER PRIMARY KEY, " + + ProviderMeta.ProviderTableMeta.UPLOADS_LOCAL_PATH + TEXT + + ProviderMeta.ProviderTableMeta.UPLOADS_REMOTE_PATH + TEXT + + ProviderMeta.ProviderTableMeta.UPLOADS_ACCOUNT_NAME + TEXT + + ProviderMeta.ProviderTableMeta.UPLOADS_FILE_SIZE + " LONG, " + + ProviderMeta.ProviderTableMeta.UPLOADS_STATUS + INTEGER // UploadStatus + + ProviderMeta.ProviderTableMeta.UPLOADS_LOCAL_BEHAVIOUR + INTEGER // Upload LocalBehaviour + + ProviderMeta.ProviderTableMeta.UPLOADS_UPLOAD_TIME + INTEGER + + ProviderMeta.ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY + INTEGER // boolean + + ProviderMeta.ProviderTableMeta.UPLOADS_IS_CREATE_REMOTE_FOLDER + INTEGER // boolean + + ProviderMeta.ProviderTableMeta.UPLOADS_UPLOAD_END_TIMESTAMP + INTEGER + + ProviderMeta.ProviderTableMeta.UPLOADS_LAST_RESULT + INTEGER // Upload LastResult + + ProviderMeta.ProviderTableMeta.UPLOADS_IS_WHILE_CHARGING_ONLY + INTEGER // boolean + + ProviderMeta.ProviderTableMeta.UPLOADS_IS_WIFI_ONLY + INTEGER // boolean + + ProviderMeta.ProviderTableMeta.UPLOADS_CREATED_BY + INTEGER // Upload createdBy + + ProviderMeta.ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN + " TEXT );"); + + /* before: + // PRIMARY KEY should always imply NOT NULL. Unfortunately, due to a + // bug in some early versions, this is not the case in SQLite. + //db.execSQL("CREATE TABLE " + TABLE_UPLOAD + " (" + " path TEXT PRIMARY KEY NOT NULL UNIQUE," + // + " uploadStatus INTEGER NOT NULL, uploadObject TEXT NOT NULL);"); + // uploadStatus is used to easy filtering, it has precedence over + // uploadObject.getUploadStatus() + */ + } + + private boolean checkIfColumnExists(SupportSQLiteDatabase database, String table, String column) { + Cursor cursor = database.query("SELECT * FROM " + table + " LIMIT 0"); + boolean exists = cursor.getColumnIndex(column) != -1; + cursor.close(); + + return exists; + } + + +} diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/Migration67to68.kt b/app/src/main/java/com/nextcloud/client/database/migrations/Migration67to68.kt new file mode 100644 index 000000000000..ebfd474d6736 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/Migration67to68.kt @@ -0,0 +1,80 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.nextcloud.client.database.migrations.DatabaseMigrationUtil.KEYWORD_NOT_NULL +import com.nextcloud.client.database.migrations.DatabaseMigrationUtil.TYPE_INTEGER +import com.nextcloud.client.database.migrations.DatabaseMigrationUtil.TYPE_TEXT + +/** + * Migration from version 67 to 68. + * + * This migration makes the local_id column NOT NULL, with -1 as a default value. + */ +@Suppress("MagicNumber") +class Migration67to68 : Migration(67, 68) { + override fun migrate(db: SupportSQLiteDatabase) { + val tableName = "filelist" + val newTableTempName = "${tableName}_new" + val newColumns = mapOf( + "_id" to DatabaseMigrationUtil.TYPE_INTEGER_PRIMARY_KEY, + "filename" to TYPE_TEXT, + "encrypted_filename" to TYPE_TEXT, + "path" to TYPE_TEXT, + "path_decrypted" to TYPE_TEXT, + "parent" to TYPE_INTEGER, + "created" to TYPE_INTEGER, + "modified" to TYPE_INTEGER, + "content_type" to TYPE_TEXT, + "content_length" to TYPE_INTEGER, + "media_path" to TYPE_TEXT, + "file_owner" to TYPE_TEXT, + "last_sync_date" to TYPE_INTEGER, + "last_sync_date_for_data" to TYPE_INTEGER, + "modified_at_last_sync_for_data" to TYPE_INTEGER, + "etag" to TYPE_TEXT, + "etag_on_server" to TYPE_TEXT, + "share_by_link" to TYPE_INTEGER, + "permissions" to TYPE_TEXT, + "remote_id" to TYPE_TEXT, + "local_id" to "$TYPE_INTEGER $KEYWORD_NOT_NULL DEFAULT -1", + "update_thumbnail" to TYPE_INTEGER, + "is_downloading" to TYPE_INTEGER, + "favorite" to TYPE_INTEGER, + "is_encrypted" to TYPE_INTEGER, + "etag_in_conflict" to TYPE_TEXT, + "shared_via_users" to TYPE_INTEGER, + "mount_type" to TYPE_INTEGER, + "has_preview" to TYPE_INTEGER, + "unread_comments_count" to TYPE_INTEGER, + "owner_id" to TYPE_TEXT, + "owner_display_name" to TYPE_TEXT, + "note" to TYPE_TEXT, + "sharees" to TYPE_TEXT, + "rich_workspace" to TYPE_TEXT, + "metadata_size" to TYPE_TEXT, + "locked" to TYPE_INTEGER, + "lock_type" to TYPE_INTEGER, + "lock_owner" to TYPE_TEXT, + "lock_owner_display_name" to TYPE_TEXT, + "lock_owner_editor" to TYPE_TEXT, + "lock_timestamp" to TYPE_INTEGER, + "lock_timeout" to TYPE_INTEGER, + "lock_token" to TYPE_TEXT + ) + + DatabaseMigrationUtil.migrateTable(db, "filelist", newColumns) { columnName -> + when (columnName) { + "local_id" -> "IFNULL(local_id, -1)" + else -> columnName + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/Migration88to89.kt b/app/src/main/java/com/nextcloud/client/database/migrations/Migration88to89.kt new file mode 100644 index 000000000000..895b29777340 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/Migration88to89.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.nextcloud.client.database.migrations.model.SQLiteColumnType +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta + +@Suppress("MagicNumber") +val MIGRATION_88_89 = object : Migration(88, 89) { + override fun migrate(db: SupportSQLiteDatabase) { + DatabaseMigrationUtil.addColumnIfNotExists( + db, + ProviderTableMeta.FILE_TABLE_NAME, + ProviderTableMeta.FILE_UPLOADED, + SQLiteColumnType.INTEGER_DEFAULT_NULL + ) + DatabaseMigrationUtil.addColumnIfNotExists( + db, + ProviderTableMeta.CAPABILITIES_TABLE_NAME, + ProviderTableMeta.CAPABILITIES_NOTES_FOLDER_PATH, + SQLiteColumnType.TEXT_DEFAULT_NULL + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/Migration97to98.kt b/app/src/main/java/com/nextcloud/client/database/migrations/Migration97to98.kt new file mode 100644 index 000000000000..6a35c00ccabd --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/Migration97to98.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.nextcloud.client.database.migrations.model.SQLiteColumnType +import com.owncloud.android.db.ProviderMeta + +@Suppress("MagicNumber") +val MIGRATION_97_98 = object : Migration(97, 98) { + override fun migrate(db: SupportSQLiteDatabase) { + DatabaseMigrationUtil.addColumnIfNotExists( + db, + ProviderMeta.ProviderTableMeta.UPLOADS_TABLE_NAME, + ProviderMeta.ProviderTableMeta.UPLOADS_UPLOAD_END_TIMESTAMP_LONG, + SQLiteColumnType.INTEGER_DEFAULT_NULL + ) + + DatabaseMigrationUtil.addColumnIfNotExists( + db, + ProviderMeta.ProviderTableMeta.CAPABILITIES_TABLE_NAME, + ProviderMeta.ProviderTableMeta.CAPABILITIES_CLIENT_INTEGRATION_JSON, + SQLiteColumnType.TEXT_DEFAULT_NULL + ) + + DatabaseMigrationUtil.resetCapabilities(db) + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/Migration99to100.kt b/app/src/main/java/com/nextcloud/client/database/migrations/Migration99to100.kt new file mode 100644 index 000000000000..95bc3fca19f7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/Migration99to100.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +@Suppress("MagicNumber", "LongMethod") +val MIGRATION_99_100 = object : Migration(99, 100) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS capabilities") + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS capabilities ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT, + version_mayor INTEGER, + version_minor INTEGER, + version_micro INTEGER, + version_string TEXT, + version_edition TEXT, + extended_support INTEGER, + core_pollinterval INTEGER, + sharing_api_enabled INTEGER, + sharing_public_enabled INTEGER, + sharing_public_password_enforced INTEGER, + sharing_public_ask_for_optional_password INTEGER, + sharing_public_expire_date_enabled INTEGER, + sharing_public_expire_date_days INTEGER, + sharing_public_expire_date_enforced INTEGER, + sharing_public_send_mail INTEGER, + sharing_public_upload INTEGER, + sharing_user_send_mail INTEGER, + sharing_resharing INTEGER, + sharing_federation_outgoing INTEGER, + sharing_federation_incoming INTEGER, + files_bigfilechunking INTEGER, + files_undelete INTEGER, + files_versioning INTEGER, + files_locking_version TEXT, + external_links INTEGER, + server_name TEXT, + server_color TEXT, + server_text_color TEXT, + server_element_color TEXT, + background_url TEXT, + server_slogan TEXT, + server_logo TEXT, + background_default INTEGER, + background_plain INTEGER, + end_to_end_encryption INTEGER, + end_to_end_encryption_keys_exist INTEGER, + end_to_end_encryption_api_version TEXT, + activity INTEGER, + richdocument INTEGER, + recommendation INTEGER, + richdocument_mimetype_list TEXT, + richdocument_optional_mimetype_list TEXT, + richdocument_direct_editing INTEGER, + richdocument_direct_templates INTEGER, + richdocument_product_name TEXT, + direct_editing_etag TEXT, + etag TEXT, + user_status INTEGER, + user_status_supports_emoji INTEGER, + user_status_supports_busy INTEGER, + assistant INTEGER, + groupfolders INTEGER, + drop_account INTEGER, + security_guard INTEGER, + forbidden_filename_characters TEXT, + forbidden_filenames TEXT, + forbidden_filename_extensions TEXT, + forbidden_filename_basenames TEXT, + windows_compatible_filenames INTEGER, + files_download_limit INTEGER, + files_download_limit_default INTEGER, + notes_folder_path TEXT, + default_permissions INTEGER, + has_valid_subscription INTEGER, + client_integration_json TEXT + ) + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/RoomMigration.kt b/app/src/main/java/com/nextcloud/client/database/migrations/RoomMigration.kt new file mode 100644 index 000000000000..009a65fd0214 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/RoomMigration.kt @@ -0,0 +1,179 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.nextcloud.client.database.NextcloudDatabase +import com.nextcloud.client.database.migrations.DatabaseMigrationUtil.TYPE_INTEGER +import com.nextcloud.client.database.migrations.DatabaseMigrationUtil.TYPE_INTEGER_PRIMARY_KEY +import com.nextcloud.client.database.migrations.DatabaseMigrationUtil.TYPE_TEXT + +class RoomMigration : Migration(NextcloudDatabase.FIRST_ROOM_DB_VERSION - 1, NextcloudDatabase.FIRST_ROOM_DB_VERSION) { + + override fun migrate(db: SupportSQLiteDatabase) { + migrateFilesystemTable(db) + migrateUploadsTable(db) + migrateCapabilitiesTable(db) + migrateFilesTable(db) + } + + /** + * filesystem table: STRING converted to TEXT + */ + private fun migrateFilesystemTable(db: SupportSQLiteDatabase) { + val newColumns = mapOf( + "_id" to TYPE_INTEGER_PRIMARY_KEY, + "local_path" to TYPE_TEXT, + "is_folder" to TYPE_INTEGER, + "found_at" to TYPE_INTEGER, + "upload_triggered" to TYPE_INTEGER, + "syncedfolder_id" to TYPE_TEXT, + "crc32" to TYPE_TEXT, + "modified_at" to TYPE_INTEGER + ) + + DatabaseMigrationUtil.migrateTable(db, "filesystem", newColumns) + } + + /** + * uploads table: LONG converted to INTEGER + */ + private fun migrateUploadsTable(db: SupportSQLiteDatabase) { + val newColumns = mapOf( + "_id" to TYPE_INTEGER_PRIMARY_KEY, + "local_path" to TYPE_TEXT, + "remote_path" to TYPE_TEXT, + "account_name" to TYPE_TEXT, + "file_size" to TYPE_INTEGER, + "status" to TYPE_INTEGER, + "local_behaviour" to TYPE_INTEGER, + "upload_time" to TYPE_INTEGER, + "name_collision_policy" to TYPE_INTEGER, + "is_create_remote_folder" to TYPE_INTEGER, + "upload_end_timestamp" to TYPE_INTEGER, + "last_result" to TYPE_INTEGER, + "is_while_charging_only" to TYPE_INTEGER, + "is_wifi_only" to TYPE_INTEGER, + "created_by" to TYPE_INTEGER, + "folder_unlock_token" to TYPE_TEXT + ) + + DatabaseMigrationUtil.migrateTable(db, "list_of_uploads", newColumns) + } + + /** + * capabilities table: "files_drop" column removed + */ + private fun migrateCapabilitiesTable(db: SupportSQLiteDatabase) { + val newColumns = mapOf( + "_id" to TYPE_INTEGER_PRIMARY_KEY, + "account" to TYPE_TEXT, + "version_mayor" to TYPE_INTEGER, + "version_minor" to TYPE_INTEGER, + "version_micro" to TYPE_INTEGER, + "version_string" to TYPE_TEXT, + "version_edition" to TYPE_TEXT, + "extended_support" to TYPE_INTEGER, + "core_pollinterval" to TYPE_INTEGER, + "sharing_api_enabled" to TYPE_INTEGER, + "sharing_public_enabled" to TYPE_INTEGER, + "sharing_public_password_enforced" to TYPE_INTEGER, + "sharing_public_expire_date_enabled" to TYPE_INTEGER, + "sharing_public_expire_date_days" to TYPE_INTEGER, + "sharing_public_expire_date_enforced" to TYPE_INTEGER, + "sharing_public_send_mail" to TYPE_INTEGER, + "sharing_public_upload" to TYPE_INTEGER, + "sharing_user_send_mail" to TYPE_INTEGER, + "sharing_resharing" to TYPE_INTEGER, + "sharing_federation_outgoing" to TYPE_INTEGER, + "sharing_federation_incoming" to TYPE_INTEGER, + "files_bigfilechunking" to TYPE_INTEGER, + "files_undelete" to TYPE_INTEGER, + "files_versioning" to TYPE_INTEGER, + "external_links" to TYPE_INTEGER, + "server_name" to TYPE_TEXT, + "server_color" to TYPE_TEXT, + "server_text_color" to TYPE_TEXT, + "server_element_color" to TYPE_TEXT, + "server_slogan" to TYPE_TEXT, + "server_logo" to TYPE_TEXT, + "background_url" to TYPE_TEXT, + "end_to_end_encryption" to TYPE_INTEGER, + "activity" to TYPE_INTEGER, + "background_default" to TYPE_INTEGER, + "background_plain" to TYPE_INTEGER, + "richdocument" to TYPE_INTEGER, + "richdocument_mimetype_list" to TYPE_TEXT, + "richdocument_direct_editing" to TYPE_INTEGER, + "richdocument_direct_templates" to TYPE_INTEGER, + "richdocument_optional_mimetype_list" to TYPE_TEXT, + "sharing_public_ask_for_optional_password" to TYPE_INTEGER, + "richdocument_product_name" to TYPE_TEXT, + "direct_editing_etag" to TYPE_TEXT, + "user_status" to TYPE_INTEGER, + "user_status_supports_emoji" to TYPE_INTEGER, + "etag" to TYPE_TEXT, + "files_locking_version" to TYPE_TEXT + ) + + DatabaseMigrationUtil.migrateTable(db, "capabilities", newColumns) + } + + /** + * files table: "public_link" column removed + */ + private fun migrateFilesTable(db: SupportSQLiteDatabase) { + val newColumns = mapOf( + "_id" to TYPE_INTEGER_PRIMARY_KEY, + "filename" to TYPE_TEXT, + "encrypted_filename" to TYPE_TEXT, + "path" to TYPE_TEXT, + "path_decrypted" to TYPE_TEXT, + "parent" to TYPE_INTEGER, + "created" to TYPE_INTEGER, + "modified" to TYPE_INTEGER, + "content_type" to TYPE_TEXT, + "content_length" to TYPE_INTEGER, + "media_path" to TYPE_TEXT, + "file_owner" to TYPE_TEXT, + "last_sync_date" to TYPE_INTEGER, + "last_sync_date_for_data" to TYPE_INTEGER, + "modified_at_last_sync_for_data" to TYPE_INTEGER, + "etag" to TYPE_TEXT, + "etag_on_server" to TYPE_TEXT, + "share_by_link" to TYPE_INTEGER, + "permissions" to TYPE_TEXT, + "remote_id" to TYPE_TEXT, + "update_thumbnail" to TYPE_INTEGER, + "is_downloading" to TYPE_INTEGER, + "favorite" to TYPE_INTEGER, + "is_encrypted" to TYPE_INTEGER, + "etag_in_conflict" to TYPE_TEXT, + "shared_via_users" to TYPE_INTEGER, + "mount_type" to TYPE_INTEGER, + "has_preview" to TYPE_INTEGER, + "unread_comments_count" to TYPE_INTEGER, + "owner_id" to TYPE_TEXT, + "owner_display_name" to TYPE_TEXT, + "note" to TYPE_TEXT, + "sharees" to TYPE_TEXT, + "rich_workspace" to TYPE_TEXT, + "metadata_size" to TYPE_TEXT, + "locked" to TYPE_INTEGER, + "lock_type" to TYPE_INTEGER, + "lock_owner" to TYPE_TEXT, + "lock_owner_display_name" to TYPE_TEXT, + "lock_owner_editor" to TYPE_TEXT, + "lock_timestamp" to TYPE_INTEGER, + "lock_timeout" to TYPE_INTEGER, + "lock_token" to TYPE_TEXT + ) + DatabaseMigrationUtil.migrateTable(db, "filelist", newColumns) + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/model/SQLiteColumnType.kt b/app/src/main/java/com/nextcloud/client/database/migrations/model/SQLiteColumnType.kt new file mode 100644 index 000000000000..96777caface0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/model/SQLiteColumnType.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.migrations.model + +enum class SQLiteColumnType(val value: String) { + INTEGER_DEFAULT_NULL("INTEGER DEFAULT NULL"), + TEXT_DEFAULT_NULL("TEXT DEFAULT NULL") +} diff --git a/app/src/main/java/com/nextcloud/client/database/typeAdapter/OfflineOperationTypeAdapter.kt b/app/src/main/java/com/nextcloud/client/database/typeAdapter/OfflineOperationTypeAdapter.kt new file mode 100644 index 000000000000..bd1ece719313 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/typeAdapter/OfflineOperationTypeAdapter.kt @@ -0,0 +1,96 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.typeAdapter + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import com.nextcloud.model.OfflineOperationRawType +import com.nextcloud.model.OfflineOperationType + +import java.lang.reflect.Type + +class OfflineOperationTypeAdapter : + JsonSerializer, + JsonDeserializer { + + override fun serialize( + src: OfflineOperationType?, + typeOfSrc: Type?, + context: JsonSerializationContext? + ): JsonElement { + val jsonObject = JsonObject() + jsonObject.addProperty("type", src?.javaClass?.simpleName) + when (src) { + is OfflineOperationType.CreateFolder -> { + jsonObject.addProperty("type", src.type) + jsonObject.addProperty("path", src.path) + } + + is OfflineOperationType.CreateFile -> { + jsonObject.addProperty("type", src.type) + jsonObject.addProperty("localPath", src.localPath) + jsonObject.addProperty("remotePath", src.remotePath) + jsonObject.addProperty("mimeType", src.mimeType) + } + + is OfflineOperationType.RenameFile -> { + jsonObject.addProperty("type", src.type) + jsonObject.addProperty("ocFileId", src.ocFileId) + jsonObject.addProperty("newName", src.newName) + } + + is OfflineOperationType.RemoveFile -> { + jsonObject.addProperty("type", src.type) + jsonObject.addProperty("path", src.path) + } + + null -> Unit + } + + return jsonObject + } + + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): OfflineOperationType? { + val jsonObject = json?.asJsonObject ?: return null + val type = jsonObject.get("type")?.asString + return when (type) { + OfflineOperationRawType.CreateFolder.name -> OfflineOperationType.CreateFolder( + jsonObject.get("type").asString, + jsonObject.get("path").asString + ) + + OfflineOperationRawType.CreateFile.name -> OfflineOperationType.CreateFile( + jsonObject.get("type").asString, + jsonObject.get("localPath").asString, + jsonObject.get("remotePath").asString, + jsonObject.get("mimeType").asString + ) + + OfflineOperationRawType.RenameFile.name -> OfflineOperationType.RenameFile( + jsonObject.get("type").asString, + jsonObject.get("ocFileId").asLong, + jsonObject.get("newName").asString + ) + + OfflineOperationRawType.RemoveFile.name -> OfflineOperationType.RemoveFile( + jsonObject.get("type").asString, + jsonObject.get("path").asString + ) + + else -> null + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/typeConverter/OfflineOperationTypeConverter.kt b/app/src/main/java/com/nextcloud/client/database/typeConverter/OfflineOperationTypeConverter.kt new file mode 100644 index 000000000000..34366235c72c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/typeConverter/OfflineOperationTypeConverter.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.typeConverter + +import androidx.room.ProvidedTypeConverter +import androidx.room.TypeConverter +import com.google.gson.Gson +import com.nextcloud.model.OfflineOperationType +import com.google.gson.GsonBuilder +import com.nextcloud.client.database.typeAdapter.OfflineOperationTypeAdapter + +@ProvidedTypeConverter +class OfflineOperationTypeConverter { + + private val gson: Gson = GsonBuilder() + .registerTypeAdapter(OfflineOperationType::class.java, OfflineOperationTypeAdapter()) + .create() + + @TypeConverter + fun fromOfflineOperationType(type: OfflineOperationType?): String? = gson.toJson(type) + + @TypeConverter + fun toOfflineOperationType(type: String?): OfflineOperationType? = + gson.fromJson(type, OfflineOperationType::class.java) +} diff --git a/app/src/main/java/com/nextcloud/client/device/BatteryStatus.kt b/app/src/main/java/com/nextcloud/client/device/BatteryStatus.kt new file mode 100644 index 000000000000..a94102630bab --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/device/BatteryStatus.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.device + +/** + * This class exposes battery status information + * in platform-independent way. + * + * @param isCharging true if device battery is charging + * @param level Battery level, from 0 to 100% + * + * @see [android.os.BatteryManager] + */ +data class BatteryStatus(val isCharging: Boolean = false, val level: Int = 0) { + + companion object { + const val BATTERY_FULL = 100 + } + + /** + * True if battery is fully loaded, false otherwise. + * Some dodgy devices can report battery charging + * status as "battery full". + */ + val isFull: Boolean get() = level >= BATTERY_FULL +} diff --git a/app/src/main/java/com/nextcloud/client/device/DeviceInfo.kt b/app/src/main/java/com/nextcloud/client/device/DeviceInfo.kt new file mode 100644 index 000000000000..48889305384f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/device/DeviceInfo.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.device + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import java.util.Locale + +class DeviceInfo { + val vendor: String = Build.MANUFACTURER.lowercase(Locale.ROOT) + val apiLevel: Int = Build.VERSION.SDK_INT + val androidVersion = Build.VERSION.RELEASE + + fun hasCamera(context: Context): Boolean = + context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) +} diff --git a/app/src/main/java/com/nextcloud/client/device/DeviceModule.kt b/app/src/main/java/com/nextcloud/client/device/DeviceModule.kt new file mode 100644 index 000000000000..139fbe665f55 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/device/DeviceModule.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.device + +import android.content.Context +import android.os.PowerManager +import dagger.Module +import dagger.Provides + +@Module +class DeviceModule { + + @Provides + fun powerManagementService(context: Context): PowerManagementService { + val platformPowerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return PowerManagementServiceImpl( + context = context, + platformPowerManager = platformPowerManager + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/device/PowerManagementService.kt b/app/src/main/java/com/nextcloud/client/device/PowerManagementService.kt new file mode 100644 index 000000000000..730ca4ec728a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/device/PowerManagementService.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.device + +/** + * This service provides all device power management + * functions. + */ +interface PowerManagementService { + + /** + * Checks if power saving mode is enabled on this device. + * On platforms that do not support power saving mode it + * evaluates to false. + * + * @see android.os.PowerManager.isPowerSaveMode + */ + val isPowerSavingEnabled: Boolean + + /** + * Checks current battery status using platform [android.os.BatteryManager] + */ + val battery: BatteryStatus +} diff --git a/app/src/main/java/com/nextcloud/client/device/PowerManagementServiceImpl.kt b/app/src/main/java/com/nextcloud/client/device/PowerManagementServiceImpl.kt new file mode 100644 index 000000000000..3c8d56c280de --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/device/PowerManagementServiceImpl.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.device + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import android.os.PowerManager +import com.nextcloud.utils.extensions.registerBroadcastReceiver +import com.owncloud.android.datamodel.ReceiverFlag + +internal class PowerManagementServiceImpl( + private val context: Context, + private val platformPowerManager: PowerManager +) : PowerManagementService { + + companion object { + @JvmStatic + fun fromContext(context: Context): PowerManagementServiceImpl { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return PowerManagementServiceImpl(context, powerManager) + } + } + + override val isPowerSavingEnabled: Boolean + get() { + return platformPowerManager.isPowerSaveMode + } + + @Suppress("MagicNumber") // 100% is 100, we're not doing Cobol + override val battery: BatteryStatus + get() { + val intent: Intent? = context.registerBroadcastReceiver( + null, + IntentFilter(Intent.ACTION_BATTERY_CHANGED), + ReceiverFlag.NotExported + ) + val isCharging = intent?.let { + when (it.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0)) { + BatteryManager.BATTERY_PLUGGED_USB -> true + BatteryManager.BATTERY_PLUGGED_AC -> true + BatteryManager.BATTERY_PLUGGED_WIRELESS -> true + else -> false + } + } ?: false + val level = intent?.let { it -> + val level: Int = it.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale: Int = it.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + (level * 100 / scale.toFloat()).toInt() + } ?: 0 + return BatteryStatus(isCharging, level) + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/ActivityInjector.kt b/app/src/main/java/com/nextcloud/client/di/ActivityInjector.kt new file mode 100644 index 000000000000..876c5d0c98a0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/ActivityInjector.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import androidx.fragment.app.FragmentActivity +import dagger.android.AndroidInjection + +class ActivityInjector : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is Injectable) { + AndroidInjection.inject(activity) + } + if (activity is FragmentActivity) { + val fm = activity.supportFragmentManager + fm.registerFragmentLifecycleCallbacks(FragmentInjector(), true) + } + } + + override fun onActivityStarted(activity: Activity) { + // unused atm + } + + override fun onActivityResumed(activity: Activity) { + // unused atm + } + + override fun onActivityPaused(activity: Activity) { + // unused atm + } + + override fun onActivityStopped(activity: Activity) { + // unused atm + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + // unused atm + } + + override fun onActivityDestroyed(activity: Activity) { + // unused atm + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.java b/app/src/main/java/com/nextcloud/client/di/AppComponent.java new file mode 100644 index 000000000000..8e1f599e5723 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.java @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.di; + +import android.app.Application; + +import com.nextcloud.appReview.InAppReviewModule; +import com.nextcloud.client.appinfo.AppInfoModule; +import com.nextcloud.client.database.DatabaseModule; +import com.nextcloud.client.device.DeviceModule; +import com.nextcloud.client.integrations.IntegrationsModule; +import com.nextcloud.client.jobs.JobsModule; +import com.nextcloud.client.jobs.download.FileDownloadHelper; +import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver; +import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver; +import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.media.BackgroundPlayerService; +import com.nextcloud.client.network.NetworkModule; +import com.nextcloud.client.onboarding.OnboardingModule; +import com.nextcloud.client.preferences.PreferencesModule; +import com.owncloud.android.MainApp; +import com.owncloud.android.media.MediaControlView; +import com.owncloud.android.ui.ThemeableSwitchPreference; +import com.owncloud.android.ui.whatsnew.ProgressIndicator; + +import javax.inject.Singleton; + +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import dagger.BindsInstance; +import dagger.Component; +import dagger.android.support.AndroidSupportInjectionModule; + +@Component(modules = { + AndroidSupportInjectionModule.class, + AppModule.class, + PreferencesModule.class, + AppInfoModule.class, + NetworkModule.class, + DeviceModule.class, + OnboardingModule.class, + ViewModelModule.class, + JobsModule.class, + IntegrationsModule.class, + InAppReviewModule.class, + ThemeModule.class, + DatabaseModule.class, + DispatcherModule.class, + VariantModule.class, +}) +@Singleton +public interface AppComponent { + + void inject(MainApp app); + + void inject(MediaControlView mediaControlView); + + @OptIn(markerClass = UnstableApi.class) + void inject(BackgroundPlayerService backgroundPlayerService); + + void inject(ThemeableSwitchPreference switchPreference); + + void inject(FileUploadHelper fileUploadHelper); + + void inject(FileDownloadHelper fileDownloadHelper); + + void inject(ProgressIndicator progressIndicator); + + void inject(FileUploadBroadcastReceiver fileUploadBroadcastReceiver); + + void inject(OfflineOperationReceiver offlineOperationReceiver); + + void inject(FolderDownloadWorkerReceiver folderDownloadWorkerReceiver); + + @Component.Builder + interface Builder { + @BindsInstance + Builder application(Application application); + + AppComponent build(); + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.java b/app/src/main/java/com/nextcloud/client/di/AppModule.java new file mode 100644 index 000000000000..58dee51ee036 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.java @@ -0,0 +1,283 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.di; + +import android.accounts.AccountManager; +import android.app.Application; +import android.app.NotificationManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.media.AudioManager; +import android.os.Handler; + +import com.nextcloud.client.account.CurrentAccountProvider; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.account.UserAccountManagerImpl; +import com.nextcloud.client.appinfo.AppInfo; +import com.nextcloud.client.core.AsyncRunner; +import com.nextcloud.client.core.Clock; +import com.nextcloud.client.core.ClockImpl; +import com.nextcloud.client.core.ThreadPoolAsyncRunner; +import com.nextcloud.client.database.dao.ArbitraryDataDao; +import com.nextcloud.client.device.DeviceInfo; +import com.nextcloud.client.jobs.operation.FileOperationHelper; +import com.nextcloud.client.logger.FileLogHandler; +import com.nextcloud.client.logger.Logger; +import com.nextcloud.client.logger.LoggerImpl; +import com.nextcloud.client.logger.LogsRepository; +import com.nextcloud.client.migrations.Migrations; +import com.nextcloud.client.migrations.MigrationsDb; +import com.nextcloud.client.migrations.MigrationsManager; +import com.nextcloud.client.migrations.MigrationsManagerImpl; +import com.nextcloud.client.network.ClientFactory; +import com.nextcloud.client.notifications.AppNotificationManager; +import com.nextcloud.client.notifications.AppNotificationManagerImpl; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.client.utils.Throttler; +import com.owncloud.android.providers.UsersAndGroupsSearchConfig; +import com.owncloud.android.authentication.PassCodeManager; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.SyncedFolderProvider; +import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository; +import com.owncloud.android.ui.activities.data.activities.ActivitiesServiceApi; +import com.owncloud.android.ui.activities.data.activities.ActivitiesServiceApiImpl; +import com.owncloud.android.ui.activities.data.activities.RemoteActivitiesRepository; +import com.owncloud.android.ui.activities.data.files.FilesRepository; +import com.owncloud.android.ui.activities.data.files.FilesServiceApiImpl; +import com.owncloud.android.ui.activities.data.files.RemoteFilesRepository; +import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator; +import com.owncloud.android.utils.overlay.OverlayManager; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import org.greenrobot.eventbus.EventBus; + +import java.io.File; + +import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Singleton; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import dagger.Module; +import dagger.Provides; + +@Module(includes = {ComponentsModule.class, VariantComponentsModule.class, BuildTypeComponentsModule.class}) +class AppModule { + + @Provides + AccountManager accountManager(Application application) { + return (AccountManager) application.getSystemService(Context.ACCOUNT_SERVICE); + } + + @Provides + Context context(Application application) { + return application; + } + + @Provides + PackageManager packageManager(Application application) { + return application.getPackageManager(); + } + + @Provides + ContentResolver contentResolver(Context context) { + return context.getContentResolver(); + } + + @Provides + Resources resources(Application application) { + return application.getResources(); + } + + @Provides + UserAccountManager userAccountManager( + Context context, + AccountManager accountManager) { + return new UserAccountManagerImpl(context, accountManager); + } + + @Provides + ArbitraryDataProvider arbitraryDataProvider(ArbitraryDataDao dao) { + return new ArbitraryDataProviderImpl(dao); + } + + @Provides + SyncedFolderProvider syncedFolderProvider(ContentResolver contentResolver, + AppPreferences appPreferences, + Clock clock) { + return new SyncedFolderProvider(contentResolver, appPreferences, clock); + } + + @Provides + ActivitiesServiceApi activitiesServiceApi(UserAccountManager accountManager) { + return new ActivitiesServiceApiImpl(accountManager); + } + + @Provides + ActivitiesRepository activitiesRepository(ActivitiesServiceApi api) { + return new RemoteActivitiesRepository(api); + } + + @Provides + FilesRepository filesRepository(UserAccountManager accountManager, ClientFactory clientFactory) { + return new RemoteFilesRepository(new FilesServiceApiImpl(accountManager, clientFactory)); + } + + @Provides + UploadsStorageManager uploadsStorageManager(CurrentAccountProvider currentAccountProvider, + Context context) { + return new UploadsStorageManager(currentAccountProvider, context.getContentResolver()); + } + + @Provides + FileDataStorageManager fileDataStorageManager(CurrentAccountProvider currentAccountProvider, + Context context) { + return new FileDataStorageManager(currentAccountProvider.getUser(), context.getContentResolver()); + } + + @Provides + CurrentAccountProvider currentAccountProvider(UserAccountManager accountManager) { + return accountManager; + } + + @Provides + DeviceInfo deviceInfo() { + return new DeviceInfo(); + } + + @Provides + @Singleton + Clock clock() { + return new ClockImpl(); + } + + @Provides + @Singleton + Logger logger(Context context, Clock clock) { + File logDir = new File(context.getFilesDir(), "logs"); + FileLogHandler handler = new FileLogHandler(logDir, "log.txt", 1024 * 1024); + LoggerImpl logger = new LoggerImpl(clock, handler, new Handler(), 1000); + logger.start(); + return logger; + } + + @Provides + @Singleton + LogsRepository logsRepository(Logger logger) { + return (LogsRepository) logger; + } + + @Provides + @Singleton + AsyncRunner uiAsyncRunner() { + Handler uiHandler = new Handler(); + return new ThreadPoolAsyncRunner(uiHandler, 4, "ui"); + } + + @Provides + @Singleton + @Named("io") + AsyncRunner ioAsyncRunner() { + Handler uiHandler = new Handler(); + return new ThreadPoolAsyncRunner(uiHandler, 8, "io"); + } + + @Provides + NotificationManager notificationManager(Context context) { + return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + } + + @Provides + AudioManager audioManager(Context context) { + return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + } + + @Provides + @Singleton + EventBus eventBus() { + return EventBus.getDefault(); + } + + @Provides + @Singleton + MigrationsDb migrationsDb(Application application) { + SharedPreferences store = application.getSharedPreferences("migrations", Context.MODE_PRIVATE); + return new MigrationsDb(store); + } + + @Provides + @Singleton + MigrationsManager migrationsManager(MigrationsDb migrationsDb, + AppInfo appInfo, + AsyncRunner asyncRunner, + Migrations migrations) { + return new MigrationsManagerImpl(appInfo, migrationsDb, asyncRunner, migrations.getSteps()); + } + + @Provides + @Singleton + AppNotificationManager notificationsManager(Context context, + NotificationManager platformNotificationsManager, + Provider viewThemeUtilsProvider) { + return new AppNotificationManagerImpl(context, + context.getResources(), + platformNotificationsManager, + viewThemeUtilsProvider.get()); + } + + @Provides + LocalBroadcastManager localBroadcastManager(Context context) { + return LocalBroadcastManager.getInstance(context); + } + + @Provides + Throttler throttler(Clock clock) { + return new Throttler(clock); + } + + @Provides + @Singleton + PassCodeManager passCodeManager(AppPreferences preferences, Clock clock) { + return new PassCodeManager(preferences, clock); + } + + @Provides + FileOperationHelper fileOperationHelper(CurrentAccountProvider currentAccountProvider, Context context) { + return new FileOperationHelper(currentAccountProvider.getUser(), context, fileDataStorageManager(currentAccountProvider, context)); + } + + @Provides + @Singleton + UsersAndGroupsSearchConfig userAndGroupSearchConfig() { + return new UsersAndGroupsSearchConfig(); + } + + + @Provides + @Singleton + CertificateValidator certificateValidator() { + return new CertificateValidator(); + } + + @Provides + @Singleton + OverlayManager overlayManager( + SyncedFolderProvider syncedFolderProvider, + AppPreferences appPreferences, + ViewThemeUtils viewThemeUtils, + Context context, + UserAccountManager accountManager) { + return new OverlayManager(syncedFolderProvider, appPreferences, viewThemeUtils, context, accountManager); + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java new file mode 100644 index 000000000000..9a0b4193d292 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -0,0 +1,519 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di; + +import com.nextcloud.client.documentscan.DocumentScanActivity; +import com.nextcloud.client.editimage.EditImageActivity; +import com.nextcloud.client.etm.EtmActivity; +import com.nextcloud.client.etm.pages.EtmBackgroundJobsFragment; +import com.nextcloud.client.jobs.BackgroundJobManagerImpl; +import com.nextcloud.client.jobs.NotificationWork; +import com.nextcloud.client.jobs.TestJob; +import com.nextcloud.client.jobs.transfer.FileTransferService; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.logger.ui.LogsActivity; +import com.nextcloud.client.logger.ui.LogsViewModel; +import com.nextcloud.client.media.BackgroundPlayerService; +import com.nextcloud.client.media.PlayerService; +import com.nextcloud.client.migrations.Migrations; +import com.nextcloud.client.onboarding.FirstRunActivity; +import com.nextcloud.client.onboarding.WhatsNewActivity; +import com.nextcloud.client.widget.DashboardWidgetConfigurationActivity; +import com.nextcloud.client.widget.DashboardWidgetProvider; +import com.nextcloud.client.widget.DashboardWidgetService; +import com.nextcloud.receiver.NetworkChangeReceiver; +import com.nextcloud.ui.ChooseAccountDialogFragment; +import com.nextcloud.ui.ChooseStorageLocationDialogFragment; +import com.nextcloud.ui.ImageDetailFragment; +import com.nextcloud.ui.SetOnlineStatusBottomSheet; +import com.nextcloud.ui.SetStatusMessageBottomSheet; +import com.nextcloud.ui.composeActivity.ComposeActivity; +import com.nextcloud.ui.fileactions.FileActionsBottomSheet; +import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet; +import com.nmc.android.ui.LauncherActivity; +import com.owncloud.android.MainApp; +import com.owncloud.android.authentication.AuthenticatorActivity; +import com.owncloud.android.authentication.DeepLinkLoginActivity; +import com.owncloud.android.files.BootupBroadcastReceiver; +import com.owncloud.android.providers.DiskLruImageCacheFileProvider; +import com.owncloud.android.providers.DocumentsStorageProvider; +import com.owncloud.android.providers.FileContentProvider; +import com.owncloud.android.providers.UsersAndGroupsSearchProvider; +import com.owncloud.android.services.AccountManagerService; +import com.owncloud.android.services.OperationsService; +import com.owncloud.android.syncadapter.FileSyncService; +import com.owncloud.android.ui.activity.BaseActivity; +import com.owncloud.android.ui.activity.ConflictsResolveActivity; +import com.owncloud.android.ui.activity.ContactsPreferenceActivity; +import com.owncloud.android.ui.activity.CopyToClipboardActivity; +import com.owncloud.android.ui.activity.DrawerActivity; +import com.owncloud.android.ui.activity.ErrorsWhileCopyingHandlerActivity; +import com.owncloud.android.ui.activity.ExternalSiteWebView; +import com.owncloud.android.ui.activity.FileActivity; +import com.owncloud.android.ui.activity.FileDisplayActivity; +import com.owncloud.android.ui.activity.FilePickerActivity; +import com.owncloud.android.ui.activity.FolderPickerActivity; +import com.owncloud.android.ui.activity.InternalTwoWaySyncActivity; +import com.owncloud.android.ui.activity.ManageAccountsActivity; +import com.owncloud.android.ui.activity.ManageSpaceActivity; +import com.owncloud.android.ui.activity.PassCodeActivity; +import com.owncloud.android.ui.activity.ReceiveExternalFilesActivity; +import com.owncloud.android.ui.activity.RequestCredentialsActivity; +import com.owncloud.android.ui.activity.RichDocumentsEditorWebView; +import com.owncloud.android.ui.activity.SettingsActivity; +import com.owncloud.android.ui.activity.ShareActivity; +import com.owncloud.android.ui.activity.SsoGrantPermissionActivity; +import com.owncloud.android.ui.activity.SyncedFoldersActivity; +import com.owncloud.android.ui.activity.TextEditorWebView; +import com.owncloud.android.ui.activity.ToolbarActivity; +import com.owncloud.android.ui.activity.UploadFilesActivity; +import com.owncloud.android.ui.activity.UploadListActivity; +import com.owncloud.android.ui.activity.UserInfoActivity; +import com.owncloud.android.ui.dialog.AccountRemovalDialog; +import com.owncloud.android.ui.dialog.AppPassCodeDialog; +import com.owncloud.android.ui.dialog.ChooseRichDocumentsTemplateDialogFragment; +import com.owncloud.android.ui.dialog.ChooseTemplateDialogFragment; +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment; +import com.owncloud.android.ui.dialog.ConflictsResolveDialog; +import com.owncloud.android.ui.dialog.CreateFolderDialogFragment; +import com.owncloud.android.ui.dialog.ExpirationDatePickerDialogFragment; +import com.owncloud.android.ui.dialog.IndeterminateProgressDialog; +import com.owncloud.android.ui.dialog.LoadingDialog; +import com.owncloud.android.ui.dialog.LocalStoragePathPickerDialogFragment; +import com.owncloud.android.ui.dialog.MultipleAccountsDialog; +import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment; +import com.owncloud.android.ui.dialog.RenameFileDialogFragment; +import com.owncloud.android.ui.dialog.SendFilesDialog; +import com.owncloud.android.ui.dialog.SendShareDialog; +import com.owncloud.android.ui.dialog.SharePasswordDialogFragment; +import com.owncloud.android.ui.dialog.SortingOrderDialogFragment; +import com.owncloud.android.ui.dialog.SslUntrustedCertDialog; +import com.owncloud.android.ui.dialog.StoragePermissionDialogFragment; +import com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragment; +import com.owncloud.android.ui.dialog.SyncedFolderPreferencesDialogFragment; +import com.owncloud.android.ui.dialog.TermsOfServiceDialog; +import com.owncloud.android.ui.dialog.ThemeSelectionDialog; +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment; +import com.owncloud.android.ui.fragment.ActivitiesFragment; +import com.owncloud.android.ui.fragment.ExtendedListFragment; +import com.owncloud.android.ui.fragment.FeatureFragment; +import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment; +import com.owncloud.android.ui.fragment.FileDetailFragment; +import com.owncloud.android.ui.fragment.FileDetailSharingFragment; +import com.owncloud.android.ui.fragment.FileDetailsSharingProcessFragment; +import com.owncloud.android.ui.fragment.GalleryFragment; +import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog; +import com.owncloud.android.ui.fragment.GroupfolderListFragment; +import com.owncloud.android.ui.fragment.LocalFileListFragment; +import com.owncloud.android.ui.fragment.OCFileListBottomSheetDialog; +import com.owncloud.android.ui.fragment.OCFileListFragment; +import com.owncloud.android.ui.fragment.SharedListFragment; +import com.owncloud.android.ui.fragment.UnifiedSearchFragment; +import com.owncloud.android.ui.fragment.community.CommunityFragment; +import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment; +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment; +import com.owncloud.android.ui.fragment.notifications.NotificationsFragment; +import com.owncloud.android.ui.navigation.NavigatorActivity; +import com.owncloud.android.ui.preview.FileDownloadFragment; +import com.owncloud.android.ui.preview.PreviewBitmapActivity; +import com.owncloud.android.ui.preview.PreviewImageActivity; +import com.owncloud.android.ui.preview.PreviewImageFragment; +import com.owncloud.android.ui.preview.PreviewMediaActivity; +import com.owncloud.android.ui.preview.PreviewMediaFragment; +import com.owncloud.android.ui.preview.PreviewTextFileFragment; +import com.owncloud.android.ui.preview.PreviewTextFragment; +import com.owncloud.android.ui.preview.PreviewTextStringFragment; +import com.owncloud.android.ui.preview.pdf.PreviewPdfFragment; +import com.owncloud.android.ui.trashbin.TrashbinActivity; + +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import dagger.Module; +import dagger.android.ContributesAndroidInjector; + +/** + * Register classes that require dependency injection. This class is used by Dagger compiler only. + */ +@Module +abstract class ComponentsModule { + @ContributesAndroidInjector + abstract ActivitiesFragment activitiesFragment(); + + @ContributesAndroidInjector + abstract NotificationsFragment notificationFragment(); + + @ContributesAndroidInjector + abstract AuthenticatorActivity authenticatorActivity(); + + @ContributesAndroidInjector + abstract BaseActivity baseActivity(); + + @ContributesAndroidInjector + abstract ConflictsResolveActivity conflictsResolveActivity(); + + @ContributesAndroidInjector + abstract ContactsPreferenceActivity contactsPreferenceActivity(); + + @ContributesAndroidInjector + abstract CopyToClipboardActivity copyToClipboardActivity(); + + @ContributesAndroidInjector + abstract DeepLinkLoginActivity deepLinkLoginActivity(); + + @ContributesAndroidInjector + abstract DrawerActivity drawerActivity(); + + @ContributesAndroidInjector + abstract ErrorsWhileCopyingHandlerActivity errorsWhileCopyingHandlerActivity(); + + @ContributesAndroidInjector + abstract ExternalSiteWebView externalSiteWebView(); + + @ContributesAndroidInjector + abstract FileDisplayActivity fileDisplayActivity(); + + @ContributesAndroidInjector + abstract FilePickerActivity filePickerActivity(); + + @ContributesAndroidInjector + abstract FirstRunActivity firstRunActivity(); + + @ContributesAndroidInjector + abstract FolderPickerActivity folderPickerActivity(); + + @ContributesAndroidInjector + abstract LogsActivity logsActivity(); + + @ContributesAndroidInjector + abstract ManageAccountsActivity manageAccountsActivity(); + + @ContributesAndroidInjector + abstract ManageSpaceActivity manageSpaceActivity(); + + @ContributesAndroidInjector + abstract ComposeActivity composeActivity(); + + @ContributesAndroidInjector + abstract PassCodeActivity passCodeActivity(); + + @ContributesAndroidInjector + abstract PreviewImageActivity previewImageActivity(); + + @ContributesAndroidInjector + abstract PreviewMediaActivity previewMediaActivity(); + + @ContributesAndroidInjector + abstract ReceiveExternalFilesActivity receiveExternalFilesActivity(); + + @ContributesAndroidInjector + abstract RequestCredentialsActivity requestCredentialsActivity(); + + @ContributesAndroidInjector + abstract SettingsActivity settingsActivity(); + + @ContributesAndroidInjector + abstract ShareActivity shareActivity(); + + @ContributesAndroidInjector + abstract SsoGrantPermissionActivity ssoGrantPermissionActivity(); + + @ContributesAndroidInjector + abstract SyncedFoldersActivity syncedFoldersActivity(); + + @ContributesAndroidInjector + abstract TrashbinActivity trashbinActivity(); + + @ContributesAndroidInjector + abstract TrashbinFileActionsBottomSheet trashbinFileActionsBottomSheet(); + + @ContributesAndroidInjector + abstract UploadFilesActivity uploadFilesActivity(); + + @ContributesAndroidInjector + abstract UploadListActivity uploadListActivity(); + + @ContributesAndroidInjector + abstract UserInfoActivity userInfoActivity(); + + @ContributesAndroidInjector + abstract WhatsNewActivity whatsNewActivity(); + + @ContributesAndroidInjector + abstract EtmActivity etmActivity(); + + @ContributesAndroidInjector + abstract RichDocumentsEditorWebView richDocumentsWebView(); + + @ContributesAndroidInjector + abstract TextEditorWebView textEditorWebView(); + + @ContributesAndroidInjector + abstract ExtendedListFragment extendedListFragment(); + + @ContributesAndroidInjector + abstract FileDetailFragment fileDetailFragment(); + + @ContributesAndroidInjector + abstract LocalFileListFragment localFileListFragment(); + + @ContributesAndroidInjector + abstract OCFileListFragment ocFileListFragment(); + + @ContributesAndroidInjector + abstract FileDetailActivitiesFragment fileDetailActivitiesFragment(); + + @ContributesAndroidInjector + abstract FileDetailsSharingProcessFragment fileDetailsSharingProcessFragment(); + + @ContributesAndroidInjector + abstract FileDetailSharingFragment fileDetailSharingFragment(); + + @ContributesAndroidInjector + abstract ChooseTemplateDialogFragment chooseTemplateDialogFragment(); + + @ContributesAndroidInjector + abstract AccountRemovalDialog accountRemovalDialog(); + + @ContributesAndroidInjector + abstract ChooseRichDocumentsTemplateDialogFragment chooseRichDocumentsTemplateDialogFragment(); + + @ContributesAndroidInjector + abstract BackupFragment contactsBackupFragment(); + + @ContributesAndroidInjector + abstract PreviewImageFragment previewImageFragment(); + + @ContributesAndroidInjector + abstract BackupListFragment chooseContactListFragment(); + + @ContributesAndroidInjector + abstract PreviewMediaFragment previewMediaFragment(); + + @ContributesAndroidInjector + abstract PreviewTextFragment previewTextFragment(); + + @ContributesAndroidInjector + abstract ChooseAccountDialogFragment chooseAccountDialogFragment(); + + @ContributesAndroidInjector + abstract SetOnlineStatusBottomSheet setOnlineStatusBottomSheet(); + + @ContributesAndroidInjector + abstract PreviewTextFileFragment previewTextFileFragment(); + + @ContributesAndroidInjector + abstract PreviewTextStringFragment previewTextStringFragment(); + + @ContributesAndroidInjector + abstract UnifiedSearchFragment searchFragment(); + + @ContributesAndroidInjector + abstract GalleryFragment photoFragment(); + + @ContributesAndroidInjector + abstract MultipleAccountsDialog multipleAccountsDialog(); + + @ContributesAndroidInjector + abstract ReceiveExternalFilesActivity.DialogInputUploadFilename dialogInputUploadFilename(); + + @ContributesAndroidInjector + abstract BootupBroadcastReceiver bootupBroadcastReceiver(); + + @ContributesAndroidInjector + abstract NetworkChangeReceiver networkChangeReceiver(); + + @ContributesAndroidInjector + abstract NotificationWork.NotificationReceiver notificationWorkBroadcastReceiver(); + + @ContributesAndroidInjector + abstract FileContentProvider fileContentProvider(); + + @ContributesAndroidInjector + abstract UsersAndGroupsSearchProvider usersAndGroupsSearchProvider(); + + @ContributesAndroidInjector + abstract DiskLruImageCacheFileProvider diskLruImageCacheFileProvider(); + + @ContributesAndroidInjector + abstract DocumentsStorageProvider documentsStorageProvider(); + + @ContributesAndroidInjector + abstract AccountManagerService accountManagerService(); + + @ContributesAndroidInjector + abstract OperationsService operationsService(); + + @ContributesAndroidInjector + abstract PlayerService playerService(); + + @ContributesAndroidInjector + abstract FileTransferService fileDownloaderService(); + + @ContributesAndroidInjector + abstract FileSyncService fileSyncService(); + + @ContributesAndroidInjector + abstract DashboardWidgetService dashboardWidgetService(); + + @ContributesAndroidInjector + abstract PreviewPdfFragment previewPDFFragment(); + + @ContributesAndroidInjector + abstract SharedListFragment sharedFragment(); + + @ContributesAndroidInjector + abstract FeatureFragment featureFragment(); + + @ContributesAndroidInjector + abstract IndeterminateProgressDialog indeterminateProgressDialog(); + + @ContributesAndroidInjector + abstract SortingOrderDialogFragment sortingOrderDialogFragment(); + + @ContributesAndroidInjector + abstract ConfirmationDialogFragment confirmationDialogFragment(); + + @ContributesAndroidInjector + abstract ConflictsResolveDialog conflictsResolveDialog(); + + @ContributesAndroidInjector + abstract CreateFolderDialogFragment createFolderDialogFragment(); + + @ContributesAndroidInjector + abstract ExpirationDatePickerDialogFragment expirationDatePickerDialogFragment(); + + @ContributesAndroidInjector + abstract FileActivity fileActivity(); + + @ContributesAndroidInjector + abstract FileDownloadFragment fileDownloadFragment(); + + @ContributesAndroidInjector + abstract LoadingDialog loadingDialog(); + + @ContributesAndroidInjector + abstract LocalStoragePathPickerDialogFragment localStoragePathPickerDialogFragment(); + + @ContributesAndroidInjector + abstract LogsViewModel logsViewModel(); + + @ContributesAndroidInjector + abstract MainApp mainApp(); + + @ContributesAndroidInjector + abstract Migrations migrations(); + + @ContributesAndroidInjector + abstract NotificationWork notificationWork(); + + @ContributesAndroidInjector + abstract RemoveFilesDialogFragment removeFilesDialogFragment(); + + @ContributesAndroidInjector + abstract SendShareDialog sendShareDialog(); + + @ContributesAndroidInjector + abstract SetupEncryptionDialogFragment setupEncryptionDialogFragment(); + + @ContributesAndroidInjector + abstract ChooseStorageLocationDialogFragment chooseStorageLocationDialogFragment(); + + @ContributesAndroidInjector + abstract ThemeSelectionDialog themeSelectionDialog(); + + @ContributesAndroidInjector + abstract AppPassCodeDialog appPassCodeDialog(); + + @ContributesAndroidInjector + abstract SharePasswordDialogFragment sharePasswordDialogFragment(); + + @ContributesAndroidInjector + abstract SyncedFolderPreferencesDialogFragment syncedFolderPreferencesDialogFragment(); + + @ContributesAndroidInjector + abstract ToolbarActivity toolbarActivity(); + + @ContributesAndroidInjector + abstract StoragePermissionDialogFragment storagePermissionDialogFragment(); + + @ContributesAndroidInjector + abstract OCFileListBottomSheetDialog ocfileListBottomSheetDialog(); + + @ContributesAndroidInjector + abstract RenameFileDialogFragment renameFileDialogFragment(); + + @ContributesAndroidInjector + abstract SyncFileNotEnoughSpaceDialogFragment syncFileNotEnoughSpaceDialogFragment(); + + @ContributesAndroidInjector + abstract DashboardWidgetConfigurationActivity dashboardWidgetConfigurationActivity(); + + @ContributesAndroidInjector + abstract DashboardWidgetProvider dashboardWidgetProvider(); + + @ContributesAndroidInjector + abstract GalleryFragmentBottomSheetDialog galleryFragmentBottomSheetDialog(); + + @ContributesAndroidInjector + abstract PreviewBitmapActivity previewBitmapActivity(); + + @ContributesAndroidInjector + abstract FileUploadHelper fileUploadHelper(); + + @ContributesAndroidInjector + abstract SslUntrustedCertDialog sslUntrustedCertDialog(); + + @ContributesAndroidInjector + abstract FileActionsBottomSheet fileActionsBottomSheet(); + + @ContributesAndroidInjector + abstract SendFilesDialog sendFilesDialog(); + + @ContributesAndroidInjector + abstract DocumentScanActivity documentScanActivity(); + + @ContributesAndroidInjector + abstract GroupfolderListFragment groupfolderListFragment(); + + @ContributesAndroidInjector + abstract LauncherActivity launcherActivity(); + + @ContributesAndroidInjector + abstract EditImageActivity editImageActivity(); + + @ContributesAndroidInjector + abstract ImageDetailFragment imageDetailFragment(); + + @ContributesAndroidInjector + abstract EtmBackgroundJobsFragment etmBackgroundJobsFragment(); + + @ContributesAndroidInjector + abstract BackgroundJobManagerImpl backgroundJobManagerImpl(); + + @ContributesAndroidInjector + abstract TestJob testJob(); + + @ContributesAndroidInjector + abstract InternalTwoWaySyncActivity internalTwoWaySyncActivity(); + + @OptIn(markerClass = UnstableApi.class) + @ContributesAndroidInjector + abstract BackgroundPlayerService backgroundPlayerService(); + + @ContributesAndroidInjector + abstract TermsOfServiceDialog termsOfServiceDialog(); + + @ContributesAndroidInjector + abstract SetStatusMessageBottomSheet setStatusMessageBottomSheet(); + + @ContributesAndroidInjector + abstract NavigatorActivity navigatorActivity(); + + @ContributesAndroidInjector + abstract CommunityFragment communityFragment(); +} diff --git a/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt b/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt new file mode 100644 index 000000000000..ab15cdf6e541 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DefaultDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class IoDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class MainDispatcher + +@Module +object DispatcherModule { + @DefaultDispatcher + @Provides + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @IoDispatcher + @Provides + fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @MainDispatcher + @Provides + fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main +} diff --git a/app/src/main/java/com/nextcloud/client/di/FragmentInjector.kt b/app/src/main/java/com/nextcloud/client/di/FragmentInjector.kt new file mode 100644 index 000000000000..5d76e732ae25 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/FragmentInjector.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import dagger.android.support.AndroidSupportInjection + +internal class FragmentInjector : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentPreAttached(fragmentManager: FragmentManager, fragment: Fragment, context: Context) { + super.onFragmentPreAttached(fragmentManager, fragment, context) + if (fragment is Injectable) { + try { + AndroidSupportInjection.inject(fragment) + } catch (directCause: IllegalArgumentException) { + // this provides a cause description that is a bit more friendly for developers + throw InjectorNotFoundException(fragment, directCause) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/Injectable.java b/app/src/main/java/com/nextcloud/client/di/Injectable.java new file mode 100644 index 000000000000..2edc275a5987 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/Injectable.java @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.di; + +/** + * Marks object as injectable by {@link ActivityInjector} and {@link FragmentInjector}. + *

+ * Any {@link android.app.Activity} or {@link androidx.fragment.app.Fragment} implementing + * this interface will be automatically supplied with dependencies. + *

+ * Activities are considered fully-initialized after call to {@link android.app.Activity#onCreate(Bundle)} + * (this means after {@code super.onCreate(savedStateInstance)} returns). + *

+ * Injectable Fragments are supplied with dependencies before {@link androidx.fragment.app.Fragment#onAttach(Context)}. + */ +public interface Injectable {} diff --git a/app/src/main/java/com/nextcloud/client/di/InjectorNotFoundException.java b/app/src/main/java/com/nextcloud/client/di/InjectorNotFoundException.java new file mode 100644 index 000000000000..7c802f4781e3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/InjectorNotFoundException.java @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.di; + +class InjectorNotFoundException extends RuntimeException { + private static final long serialVersionUID = 2026042918255104421L; + + InjectorNotFoundException(Object object, Throwable cause) { + super( + String.format( + "Injector not registered for %s. Have you added it to %s?", + object.getClass().getName(), + ComponentsModule.class.getName() + ), + cause + ); + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/ThemeModule.kt b/app/src/main/java/com/nextcloud/client/di/ThemeModule.kt new file mode 100644 index 000000000000..99a32ad0be83 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/ThemeModule.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import com.nextcloud.android.common.ui.theme.MaterialSchemes +import com.owncloud.android.utils.theme.MaterialSchemesProvider +import com.owncloud.android.utils.theme.MaterialSchemesProviderImpl +import com.owncloud.android.utils.theme.ThemeColorUtils +import com.owncloud.android.utils.theme.ThemeUtils +import dagger.Binds +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +internal abstract class ThemeModule { + + @Binds + abstract fun bindMaterialSchemesProvider(provider: MaterialSchemesProviderImpl): MaterialSchemesProvider + + companion object { + + @Provides + @Singleton + fun themeColorUtils(): ThemeColorUtils = ThemeColorUtils() + + @Provides + @Singleton + fun themeUtils(): ThemeUtils = ThemeUtils() + + @Provides + fun provideMaterialSchemes(materialSchemesProvider: MaterialSchemesProvider): MaterialSchemes = + materialSchemesProvider.getMaterialSchemesForCurrentUser() + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/VariantModule.kt b/app/src/main/java/com/nextcloud/client/di/VariantModule.kt new file mode 100644 index 000000000000..78899be6aec7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/VariantModule.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Philipp Hasper + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import androidx.activity.result.contract.ActivityResultContract +import com.nextcloud.client.documentscan.AppScanOptionalFeature +import dagger.Module +import dagger.Provides +import dagger.Reusable + +@Module +internal class VariantModule { + /** + * Using reflection to determine whether the ScanPageContract class from the appscan project is available. + * If yes, an instance of it is returned. If not, a stub is returned indicating the feature is not available. + * + * To make it available for your specific variant, make sure it is included in your build.gradle, + * e.g.: `"qaImplementation"(project(":appscan"))` + */ + @Provides + @Reusable + fun scanOptionalFeature(): AppScanOptionalFeature = try { + // Try to load the ScanPageContract class only if the appscan project is present + val clazz = Class.forName("com.nextcloud.appscan.ScanPageContract") + + @Suppress("UNCHECKED_CAST") + val contractInstance = + clazz.getDeclaredConstructor().newInstance() as ActivityResultContract + object : AppScanOptionalFeature() { + override fun getScanContract(): ActivityResultContract = contractInstance + } + } catch (_: ClassNotFoundException) { + // appscan module is not present in this variant + AppScanOptionalFeature.Stub + } catch (_: Exception) { + // Any reflection/instantiation error -> be safe and use stub + AppScanOptionalFeature.Stub + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelFactory.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelFactory.kt new file mode 100644 index 000000000000..08041ae60feb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelFactory.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import javax.inject.Inject +import javax.inject.Provider + +/** + * This factory provide [ViewModel] instances initialized by Dagger 2 dependency injection system. + * + * Each [javax.inject.Provider] instance accesses Dagger machinery, which provide + * fully-initialized [ViewModel] instance. + * + * @see ViewModelModule + * @see ViewModelKey + */ +class ViewModelFactory @Inject constructor( + private val viewModelProviders: Map, @JvmSuppressWildcards Provider> +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + var vmProvider: Provider? = viewModelProviders[modelClass] + + if (vmProvider == null) { + for (entry in viewModelProviders.entries) { + if (modelClass.isAssignableFrom(entry.key)) { + vmProvider = entry.value + break + } + } + } + + if (vmProvider == null) { + throw IllegalArgumentException("${modelClass.simpleName} view model class is not supported") + } + + @Suppress("UNCHECKED_CAST") + return vmProvider.get() as T + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelKey.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelKey.kt new file mode 100644 index 000000000000..baf267688132 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelKey.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import androidx.lifecycle.ViewModel +import dagger.MapKey +import kotlin.reflect.KClass + +@MustBeDocumented +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class ViewModelKey(val value: KClass) diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt new file mode 100644 index 000000000000..eb3e98a6c570 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.nextcloud.client.documentscan.DocumentScanViewModel +import com.nextcloud.client.etm.EtmViewModel +import com.nextcloud.client.logger.ui.LogsViewModel +import com.nextcloud.ui.fileactions.FileActionsViewModel +import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel +import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel +import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel +import dagger.Binds +import dagger.Module +import dagger.multibindings.IntoMap + +@Module +abstract class ViewModelModule { + @Binds + @IntoMap + @ViewModelKey(EtmViewModel::class) + abstract fun etmViewModel(vm: EtmViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(LogsViewModel::class) + abstract fun logsViewModel(vm: LogsViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(UnifiedSearchViewModel::class) + abstract fun unifiedSearchViewModel(vm: UnifiedSearchViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(PreviewPdfViewModel::class) + abstract fun previewPDFViewModel(vm: PreviewPdfViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(FileActionsViewModel::class) + abstract fun fileActionsViewModel(vm: FileActionsViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(DocumentScanViewModel::class) + abstract fun documentScanViewModel(vm: DocumentScanViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(TrashbinFileActionsViewModel::class) + abstract fun trashbinFileActionsViewModel(vm: TrashbinFileActionsViewModel): ViewModel + + @Binds + abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory +} diff --git a/app/src/main/java/com/nextcloud/client/di/package-info.java b/app/src/main/java/com/nextcloud/client/di/package-info.java new file mode 100644 index 000000000000..e7e0175a2438 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/package-info.java @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +/** + * This package contains application Dependency Injection code, based on Dagger 2. + *

+ * To enable dependency injection for a component, such as {@link android.app.Activity}, + * {@link androidx.fragment.app.Fragment} or {@link android.app.Service}, the component must be + * first registered in {@link com.nextcloud.client.di.ComponentsModule} class. + *

+ * {@link com.nextcloud.client.di.ComponentsModule} will be used by Dagger compiler to + * create an injector for a given class. + * + * @see com.nextcloud.client.di.InjectorNotFoundException + * @see dagger.android.AndroidInjection + * @see dagger.android.support.AndroidSupportInjection + */ +package com.nextcloud.client.di; diff --git a/app/src/main/java/com/nextcloud/client/documentscan/AppScanOptionalFeature.kt b/app/src/main/java/com/nextcloud/client/documentscan/AppScanOptionalFeature.kt new file mode 100644 index 000000000000..d6996e17fcd5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/documentscan/AppScanOptionalFeature.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.documentscan + +import androidx.activity.result.contract.ActivityResultContract + +abstract class AppScanOptionalFeature { + /** + * Check [isAvailable] before calling this method. + */ + abstract fun getScanContract(): ActivityResultContract + open val isAvailable: Boolean = true + + /** + * Use this in variants where the feature is not available + */ + @Suppress("unused") // used only in some variants + object Stub : AppScanOptionalFeature() { + override fun getScanContract(): ActivityResultContract = + throw UnsupportedOperationException("Document scan is not available") + + override val isAvailable = false + } +} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/DocumentPageListAdapter.kt b/app/src/main/java/com/nextcloud/client/documentscan/DocumentPageListAdapter.kt new file mode 100644 index 000000000000..17ff411712db --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/documentscan/DocumentPageListAdapter.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.documentscan + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.load +import com.owncloud.android.databinding.DocumentPageItemBinding + +class DocumentPageListAdapter : + ListAdapter(DiffItemCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DocumentPageViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = DocumentPageItemBinding.inflate(inflater, parent, false) + return DocumentPageViewHolder(binding) + } + + override fun onBindViewHolder(holder: DocumentPageViewHolder, position: Int) { + holder.bind(currentList[position]) + } + + override fun getItemCount(): Int = currentList.size + + class DocumentPageViewHolder(val binding: DocumentPageItemBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(imagePath: String) { + binding.root.load(imagePath) + } + } + + private class DiffItemCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String) = oldItem == newItem + + override fun areContentsTheSame(oldItem: String, newItem: String) = oldItem == newItem + } +} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt b/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt new file mode 100644 index 000000000000..7424948cae90 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt @@ -0,0 +1,207 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.documentscan + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.activity.result.ActivityResultLauncher +import androidx.appcompat.app.AlertDialog +import androidx.core.view.MenuProvider +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.di.ViewModelFactory +import com.nextcloud.client.logger.Logger +import com.owncloud.android.R +import com.owncloud.android.databinding.ActivityDocumentScanBinding +import com.owncloud.android.databinding.DialogScanExportTypeBinding +import com.owncloud.android.ui.activity.ToolbarActivity +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class DocumentScanActivity : + ToolbarActivity(), + Injectable { + + @Inject + lateinit var vmFactory: ViewModelFactory + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var appScanOptionalFeature: AppScanOptionalFeature + + lateinit var binding: ActivityDocumentScanBinding + + lateinit var viewModel: DocumentScanViewModel + + private var scanPage: ActivityResultLauncher? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + scanPage = registerForActivityResult(appScanOptionalFeature.getScanContract()) { result -> + viewModel.onScanPageResult(result) + } + + val folder = intent.extras?.getString(EXTRA_FOLDER) + require(folder != null) { "Folder must be provided for upload" } + + viewModel = ViewModelProvider(this, vmFactory)[DocumentScanViewModel::class.java] + viewModel.setUploadFolder(folder) + + setupViews() + + observeState() + } + + private fun setupViews() { + binding = ActivityDocumentScanBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupToolbar() + supportActionBar?.let { + it.setDisplayHomeAsUpEnabled(true) + it.setDisplayShowHomeEnabled(true) + viewThemeUtils.files.themeActionBar(this, it) + } + + viewThemeUtils.material.themeFAB(binding.fab) + binding.fab.setOnClickListener { + viewModel.onAddPageClicked() + } + + binding.pagesRecycler.layoutManager = GridLayoutManager(this, PAGE_COLUMNS) + + setupMenu() + } + + private fun setupMenu() { + addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_document_scan, menu) + menu.findItem(R.id.action_save)?.let { + viewThemeUtils.platform.colorToolbarMenuIcon(this@DocumentScanActivity, it) + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_save -> { + viewModel.onClickDone() + true + } + + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + true + } + + else -> false + } + } + ) + } + + private fun observeState() { + viewModel.uiState.observe(this, ::handleState) + } + + private fun handleState(state: DocumentScanViewModel.UIState) { + logger.d(TAG, "handleState: called with $state") + when (state) { + is DocumentScanViewModel.UIState.BaseState -> when (state) { + is DocumentScanViewModel.UIState.NormalState -> { + updateButtonsEnabled(true) + val pageList = state.pageList + updateRecycler(pageList) + if (state.shouldRequestScan) { + startPageScan() + } + } + + is DocumentScanViewModel.UIState.RequestExportState -> { + updateButtonsEnabled(false) + if (state.shouldRequestExportType) { + showExportDialog() + viewModel.onRequestTypeHandled() + } + } + } + + DocumentScanViewModel.UIState.DoneState, DocumentScanViewModel.UIState.CanceledState -> { + finish() + } + } + } + + private fun showExportDialog() { + val dialogBinding = DialogScanExportTypeBinding.inflate(layoutInflater) + + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.document_scan_export_dialog_title) + .setCancelable(true) + .setView(dialogBinding.root) + .setNegativeButton(R.string.common_cancel) { _, _ -> + viewModel.onExportCanceled() + } + .setOnCancelListener { viewModel.onExportCanceled() } + .also { + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this@DocumentScanActivity, it) + } + .create() + + viewThemeUtils.platform.colorTextButtons(dialogBinding.btnPdf, dialogBinding.btnImages) + + dialogBinding.btnPdf.setOnClickListener { + viewModel.onExportTypeSelected(DocumentScanViewModel.ExportType.PDF) + dialog.dismiss() + } + dialogBinding.btnImages.setOnClickListener { + viewModel.onExportTypeSelected(DocumentScanViewModel.ExportType.IMAGES) + dialog.dismiss() + } + + dialog.setOnShowListener { + val alertDialog = it as AlertDialog + viewThemeUtils.platform.colorTextButtons(alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE)) + } + + dialog.show() + } + + private fun updateRecycler(pageList: List) { + if (binding.pagesRecycler.adapter == null) { + binding.pagesRecycler.adapter = DocumentPageListAdapter() + } + (binding.pagesRecycler.adapter as? DocumentPageListAdapter)?.submitList(pageList) + } + + private fun updateButtonsEnabled(enabled: Boolean) { + binding.fab.isEnabled = enabled + } + + private fun startPageScan() { + logger.d(TAG, "startPageScan() called") + viewModel.onScanRequestHandled() + scanPage!!.launch(Unit) + } + + companion object { + private const val TAG = "DocumentScanActivity" + private const val PAGE_COLUMNS = 2 + const val EXTRA_FOLDER = "extra_folder" + } +} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt b/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt new file mode 100644 index 000000000000..0210ff07e135 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt @@ -0,0 +1,197 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.documentscan + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.di.IoDispatcher +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.logger.Logger +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.helpers.FileOperationsHelper +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +@Suppress("Detekt.LongParameterList") // satisfied by DI +class DocumentScanViewModel @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + app: Application, + private val logger: Logger, + private val backgroundJobManager: BackgroundJobManager, + private val currentAccountProvider: CurrentAccountProvider +) : AndroidViewModel(app) { + init { + logger.d(TAG, "DocumentScanViewModel created") + } + + sealed interface UIState { + sealed class BaseState(val pageList: List) : UIState { + val isEmpty: Boolean + get() = pageList.isEmpty() + } + + class NormalState(pageList: List = emptyList(), val shouldRequestScan: Boolean = false) : + BaseState(pageList) + + class RequestExportState(pageList: List = emptyList(), val shouldRequestExportType: Boolean = true) : + BaseState(pageList) + + object DoneState : UIState + object CanceledState : UIState + } + + private var uploadFolder: String? = null + private val initialState = UIState.NormalState(shouldRequestScan = true) + private val _uiState = MutableLiveData(initialState) + val uiState: LiveData + get() = _uiState + + /** + * @param result should be the path to the scanned page on the disk + */ + fun onScanPageResult(result: String?) { + logger.d(TAG, "onScanPageResult() called with: result = $result") + + val state = _uiState.value + require(state is UIState.NormalState) + + viewModelScope.launch(ioDispatcher) { + if (result != null) { + val newPath = renameCapturedImage(result) + val pageList = state.pageList.toMutableList() + pageList.add(newPath) + _uiState.postValue(UIState.NormalState(pageList)) + } else { + // result == null means cancellation or error + if (state.isEmpty) { + // close only if no pages have been added yet + _uiState.postValue(UIState.CanceledState) + } + } + } + } + + // TODO extract to usecase + private fun renameCapturedImage(originalPath: String): String { + val file = File(originalPath) + val renamedFile = + File( + getApplication().cacheDir.path + + File.separator + FileOperationsHelper.getCapturedImageName() + ) + file.renameTo(renamedFile) + return renamedFile.absolutePath + } + + fun onScanRequestHandled() { + val state = uiState.value + require(state is UIState.NormalState) + + _uiState.postValue(UIState.NormalState(state.pageList, shouldRequestScan = false)) + } + + fun onAddPageClicked() { + val state = uiState.value + require(state is UIState.NormalState) + if (!state.shouldRequestScan) { + _uiState.postValue(UIState.NormalState(state.pageList, shouldRequestScan = true)) + } + } + + fun onClickDone() { + val state = _uiState.value + if (state is UIState.BaseState && !state.isEmpty) { + _uiState.postValue(UIState.RequestExportState(state.pageList)) + } + } + + fun setUploadFolder(folder: String) { + this.uploadFolder = folder + } + + fun onRequestTypeHandled() { + val state = _uiState.value + require(state is UIState.RequestExportState) + _uiState.postValue(UIState.RequestExportState(state.pageList, false)) + } + + fun onExportTypeSelected(exportType: ExportType) { + val state = _uiState.value + require(state is UIState.RequestExportState) + when (exportType) { + ExportType.PDF -> { + exportToPdf(state.pageList) + } + + ExportType.IMAGES -> { + exportToImages(state.pageList) + } + } + _uiState.postValue(UIState.DoneState) + } + + private fun exportToPdf(pageList: List) { + val genPath = + getApplication().cacheDir.path + File.separator + FileOperationsHelper.getTimestampedFileName( + ".pdf" + ) + backgroundJobManager.startPdfGenerateAndUploadWork( + currentAccountProvider.user, + uploadFolder!!, + pageList, + genPath + ) + // after job is started, finish the application. + _uiState.postValue(UIState.DoneState) + } + + private fun exportToImages(pageList: List) { + val uploadPaths = pageList.map { + uploadFolder + OCFile.PATH_SEPARATOR + File(it).name + }.toTypedArray() + + FileUploadHelper.instance().uploadNewFiles( + currentAccountProvider.user, + pageList.toTypedArray(), + uploadPaths, + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.ASK_USER + ) + } + + fun onExportCanceled() { + val state = _uiState.value + if (state is UIState.BaseState) { + _uiState.postValue(UIState.NormalState(state.pageList)) + } + } + + private companion object { + private const val TAG = "DocumentScanViewModel" + } + + enum class ExportType { + PDF, + IMAGES + } +} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt b/app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt new file mode 100644 index 000000000000..0f6cfa220e22 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.documentscan + +import android.graphics.BitmapFactory +import android.graphics.pdf.PdfDocument +import com.nextcloud.client.logger.Logger +import java.io.FileOutputStream +import java.io.IOException +import javax.inject.Inject + +/** + * This class takes a list of images and generates a PDF file. + */ +class GeneratePDFUseCase @Inject constructor(private val logger: Logger) { + /** + * @param imagePaths list of image paths + * @return `true` if the PDF was generated successfully, `false` otherwise + */ + fun execute(imagePaths: List, filePath: String): Boolean = if (imagePaths.isEmpty() || filePath.isBlank()) { + logger.w(TAG, "Invalid parameters: imagePaths: $imagePaths, filePath: $filePath") + false + } else { + val document = PdfDocument() + fillDocumentPages(document, imagePaths) + writePdfToFile(filePath, document) + } + + /** + * @return `true` if the PDF was generated successfully, `false` otherwise + */ + private fun writePdfToFile(filePath: String, document: PdfDocument): Boolean = try { + val fileOutputStream = FileOutputStream(filePath) + document.writeTo(fileOutputStream) + fileOutputStream.close() + document.close() + true + } catch (ex: IOException) { + logger.e(TAG, "Error generating PDF", ex) + false + } + + private fun fillDocumentPages(document: PdfDocument, imagePaths: List) { + imagePaths.forEach { path -> + val bitmap = BitmapFactory.decodeFile(path) + val pageInfo = PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, 1).create() + val page = document.startPage(pageInfo) + page.canvas.drawBitmap(bitmap, 0f, 0f, null) + document.finishPage(page) + } + } + + companion object { + private const val TAG = "GeneratePDFUseCase" + } +} diff --git a/app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt b/app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt new file mode 100644 index 000000000000..c1115de62e35 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt @@ -0,0 +1,132 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.documentscan + +import android.app.NotificationManager +import android.content.Context +import android.graphics.BitmapFactory +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.AnonymousUser +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.logger.Logger +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.io.File +import java.security.SecureRandom + +@Suppress("Detekt.LongParameterList") // constructed only from factory method and tests +class GeneratePdfFromImagesWork( + private val appContext: Context, + private val generatePdfUseCase: GeneratePDFUseCase, + private val viewThemeUtils: ViewThemeUtils, + private val notificationManager: NotificationManager, + private val userAccountManager: UserAccountManager, + private val logger: Logger, + params: WorkerParameters +) : Worker(appContext, params) { + + override fun doWork(): Result { + val inputPaths = inputData.getStringArray(INPUT_IMAGE_FILE_PATHS)?.toList() + val outputFilePath = inputData.getString(INPUT_OUTPUT_FILE_PATH) + val uploadFolder = inputData.getString(INPUT_UPLOAD_FOLDER) + val accountName = inputData.getString(INPUT_UPLOAD_ACCOUNT) + + @Suppress("Detekt.ComplexCondition") // not that complex + require(!inputPaths.isNullOrEmpty() && outputFilePath != null && uploadFolder != null && accountName != null) { + "PDF generation work started with missing parameters:" + + " inputPaths: $inputPaths, outputFilePath: $outputFilePath," + + " uploadFolder: $uploadFolder, accountName: $accountName" + } + + val user = userAccountManager.getUser(accountName) + require(user.isPresent && user.get() !is AnonymousUser) { "Invalid or not found user" } + + logger.d( + TAG, + "PDF generation work started with parameters: inputPaths=$inputPaths," + + "outputFilePath=$outputFilePath, uploadFolder=$uploadFolder, accountName=$accountName" + ) + + val notificationId = showNotification(R.string.document_scan_pdf_generation_in_progress) + val result = generatePdfUseCase.execute(inputPaths, outputFilePath) + notificationManager.cancel(notificationId) + if (result) { + uploadFile(user.get(), uploadFolder, outputFilePath) + cleanupImages(inputPaths) + } else { + logger.w(TAG, "PDF generation failed") + showNotification(R.string.document_scan_pdf_generation_failed) + return Result.failure() + } + + logger.d(TAG, "PDF generation work finished") + return Result.success() + } + + private fun cleanupImages(inputPaths: List) { + inputPaths.forEach { + val deleted = File(it).delete() + logger.d(TAG, "Deleted $it: success = $deleted") + } + } + + private fun showNotification(@StringRes messageRes: Int): Int { + val notificationId = SecureRandom().nextInt() + val message = appContext.getString(messageRes) + + val notificationBuilder = NotificationCompat.Builder( + appContext, + NotificationUtils.NOTIFICATION_CHANNEL_GENERAL + ) + .setSmallIcon(R.drawable.notification_icon) + .setLargeIcon(BitmapFactory.decodeResource(appContext.resources, R.drawable.notification_icon)) + .setContentText(message) + .setAutoCancel(true) + + viewThemeUtils.androidx.themeNotificationCompatBuilder(appContext, notificationBuilder) + + notificationManager.notify(notificationId, notificationBuilder.build()) + + return notificationId + } + + private fun uploadFile(user: User, uploadFolder: String, pdfPath: String) { + val uploadPath = uploadFolder + OCFile.PATH_SEPARATOR + File(pdfPath).name + + FileUploadHelper().uploadNewFiles( + user, + arrayOf(pdfPath), + arrayOf(uploadPath), + // MIME type will be detected from file name + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.ASK_USER + ) + } + + companion object { + const val INPUT_IMAGE_FILE_PATHS = "input_image_file_paths" + const val INPUT_OUTPUT_FILE_PATH = "input_output_file_path" + const val INPUT_UPLOAD_FOLDER = "input_upload_folder" + const val INPUT_UPLOAD_ACCOUNT = "input_upload_account" + private const val TAG = "GeneratePdfFromImagesWo" + } +} diff --git a/app/src/main/java/com/nextcloud/client/editimage/EditImageActivity.kt b/app/src/main/java/com/nextcloud/client/editimage/EditImageActivity.kt new file mode 100644 index 000000000000..48920e1ab858 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/editimage/EditImageActivity.kt @@ -0,0 +1,190 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 ZetaTom + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.editimage + +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import com.canhub.cropper.CropImageView +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +import com.owncloud.android.databinding.ActivityEditImageBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.operations.OnRemoteOperationListener +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.MimeType +import java.io.File + +class EditImageActivity : + FileActivity(), + OnRemoteOperationListener, + CropImageView.OnSetImageUriCompleteListener, + CropImageView.OnCropImageCompleteListener, + Injectable { + + private lateinit var binding: ActivityEditImageBinding + private lateinit var file: OCFile + private lateinit var format: Bitmap.CompressFormat + + companion object { + const val EXTRA_FILE = "FILE" + const val OPEN_IMAGE_EDITOR = "OPEN_IMAGE_EDITOR" + + private val supportedMimeTypes = arrayOf( + MimeType.PNG, + MimeType.JPEG, + MimeType.WEBP, + MimeType.TIFF, + MimeType.BMP, + MimeType.HEIC + ) + + fun canBePreviewed(file: OCFile): Boolean = file.mimeType in supportedMimeTypes + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityEditImageBinding.inflate(layoutInflater) + setContentView(binding.root) + + file = intent.extras?.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + ?: throw IllegalArgumentException("Missing file argument") + + setSupportActionBar(binding.toolbar) + supportActionBar?.apply { + title = file.fileName + setDisplayHomeAsUpEnabled(true) + } + + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) + + window.navigationBarColor = getColor(R.color.black) + + setupCropper() + } + + override fun onCropImageComplete(view: CropImageView, result: CropImageView.CropResult) { + if (!result.isSuccessful) { + DisplayUtils.showSnackMessage(this, getString(R.string.image_editor_unable_to_edit_image)) + return + } + val resultUri = result.getUriFilePath(this, false) + val newFileName = file.fileName.substring(0, file.fileName.lastIndexOf('.')) + + " " + getString(R.string.image_editor_file_edited_suffix) + + resultUri?.substring(resultUri.lastIndexOf('.')) + + resultUri?.let { + FileUploadHelper().uploadNewFiles( + user = storageManager.user, + localPaths = arrayOf(it), + remotePaths = arrayOf(file.parentRemotePath + File.separator + newFileName), + createRemoteFolder = false, + createdBy = UploadFileOperation.CREATED_BY_USER, + requiresWifi = false, + requiresCharging = false, + nameCollisionPolicy = NameCollisionPolicy.RENAME, + localBehavior = FileUploadWorker.LOCAL_BEHAVIOUR_DELETE + ) + } + } + + override fun onSetImageUriComplete(view: CropImageView, uri: Uri, error: Exception?) { + if (error != null) { + DisplayUtils.showSnackMessage(this, getString(R.string.image_editor_unable_to_edit_image)) + return + } + view.visibility = View.VISIBLE + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + // add save button to action bar + menuInflater.inflate(R.menu.custom_menu_placeholder, menu) + val saveIcon = AppCompatResources.getDrawable(this, R.drawable.ic_check)?.also { + DrawableCompat.setTint(it, resources.getColor(R.color.white, theme)) + } + menu?.findItem(R.id.custom_menu_placeholder_item)?.apply { + icon = saveIcon + contentDescription = getString(R.string.common_save) + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.custom_menu_placeholder_item -> { + binding.cropImageView.croppedImageAsync(format) + finish() + true + } + + else -> { + finish() + true + } + } + + /** + * Set up image cropper and image editor control strip. + */ + private fun setupCropper() { + val cropper = binding.cropImageView + + @Suppress("MagicNumber") + binding.rotateLeft.setOnClickListener { + cropper.rotateImage(-90) + } + + @Suppress("MagicNumber") + binding.rotateRight.setOnClickListener { + cropper.rotateImage(90) + } + + binding.flipVertical.setOnClickListener { + cropper.flipImageVertically() + } + + binding.flipHorizontal.setOnClickListener { + cropper.flipImageHorizontally() + } + + cropper.setOnSetImageUriCompleteListener(this) + cropper.setOnCropImageCompleteListener(this) + cropper.setImageUriAsync(file.storageUri) + + // determine output file format + format = when (file.mimeType) { + MimeType.PNG -> Bitmap.CompressFormat.PNG + + MimeType.WEBP -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + } + + else -> Bitmap.CompressFormat.JPEG + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/errorhandling/ExceptionHandler.kt b/app/src/main/java/com/nextcloud/client/errorhandling/ExceptionHandler.kt new file mode 100644 index 000000000000..f68f17527519 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/errorhandling/ExceptionHandler.kt @@ -0,0 +1,103 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2014 Luke Owncloud + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.nextcloud.client.errorhandling + +import android.content.Context +import android.content.Intent +import android.os.Build +import com.owncloud.android.BuildConfig +import com.owncloud.android.R + +class ExceptionHandler( + private val context: Context, + private val defaultExceptionHandler: Thread.UncaughtExceptionHandler +) : Thread.UncaughtExceptionHandler { + + companion object { + private const val LINE_SEPARATOR = "\n" + private const val EXCEPTION_FORMAT_MAX_RECURSIVITY = 10 + } + + override fun uncaughtException(thread: Thread, exception: Throwable) { + @Suppress("TooGenericExceptionCaught") // this is exactly what we want here + try { + val errorReport = generateErrorReport(formatException(thread, exception)) + val intent = Intent(context, ShowErrorActivity::class.java) + intent.putExtra(ShowErrorActivity.EXTRA_ERROR_TEXT, errorReport) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + // Pass exception to OS for graceful handling - OS will report it via ADB + // and close all activities and services. + defaultExceptionHandler.uncaughtException(thread, exception) + } catch (fatalException: Exception) { + // do not recurse into custom handler if exception is thrown during + // exception handling. Pass this ultimate fatal exception to OS + defaultExceptionHandler.uncaughtException(thread, fatalException) + } + } + + private fun formatException(thread: Thread, exception: Throwable): String { + fun formatExceptionRecursive(thread: Thread, exception: Throwable, count: Int = 0): String { + if (count > EXCEPTION_FORMAT_MAX_RECURSIVITY) { + return "Max number of recursive exception causes exceeded!" + } + // print exception + val stringBuilder = StringBuilder() + val stackTrace = exception.stackTrace + stringBuilder.appendLine("Exception in thread \"${thread.name}\" $exception") + // print available stacktrace + for (element in stackTrace) { + stringBuilder.appendLine(" at $element") + } + // print cause recursively + exception.cause?.let { + stringBuilder.append("Caused by: ") + stringBuilder.append(formatExceptionRecursive(thread, it, count + 1)) + } + return stringBuilder.toString() + } + + return formatExceptionRecursive(thread, exception, 0) + } + + private fun generateErrorReport(stackTrace: String): String { + val buildNumber = context.resources.getString(R.string.buildNumber) + + val buildNumberString = when { + buildNumber.isNotEmpty() -> " (build #$buildNumber)" + else -> "" + } + + return """ + |### Cause of error + |```java + ${stackTrace.prependIndent("|")} + |``` + | + |### App information + |* ID: `${BuildConfig.APPLICATION_ID}` + |* Version: `${BuildConfig.VERSION_CODE}$buildNumberString` + |* Build flavor: `${BuildConfig.FLAVOR}` + | + |### Device information + |* Brand: `${Build.BRAND}` + |* Device: `${Build.DEVICE}` + |* Model: `${Build.MODEL}` + |* Id: `${Build.ID}` + |* Product: `${Build.PRODUCT}` + | + |### Firmware + |* SDK: `${Build.VERSION.SDK_INT}` + |* Release: `${Build.VERSION.RELEASE}` + |* Incremental: `${Build.VERSION.INCREMENTAL}` + """.trimMargin("|") + } +} diff --git a/app/src/main/java/com/nextcloud/client/errorhandling/ShowErrorActivity.kt b/app/src/main/java/com/nextcloud/client/errorhandling/ShowErrorActivity.kt new file mode 100644 index 000000000000..87c138f9b1b6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/errorhandling/ShowErrorActivity.kt @@ -0,0 +1,77 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.errorhandling + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.snackbar.Snackbar +import com.owncloud.android.R +import com.owncloud.android.databinding.ActivityShowErrorBinding +import com.owncloud.android.utils.ClipboardUtil +import com.owncloud.android.utils.DisplayUtils + +class ShowErrorActivity : AppCompatActivity() { + private lateinit var binding: ActivityShowErrorBinding + + companion object { + const val EXTRA_ERROR_TEXT = "error" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityShowErrorBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.textViewError.text = intent.getStringExtra(EXTRA_ERROR_TEXT) + + setSupportActionBar(binding.toolbarInclude.toolbar) + supportActionBar!!.title = createErrorTitle() + + val snackbar = DisplayUtils.createSnackbar( + binding.errorPageContainer, + R.string.error_report_issue_text, + Snackbar.LENGTH_INDEFINITE + ) + .setAction(R.string.error_report_issue_action) { reportIssue() } + + snackbar.show() + } + + private fun createErrorTitle() = String.format(getString(R.string.error_crash_title), getString(R.string.app_name)) + + private fun reportIssue() { + ClipboardUtil.copyToClipboard(this, binding.textViewError.text.toString(), true) + val issueLink = getString(R.string.report_issue_link) + DisplayUtils.startLinkIntent(this, issueLink) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_show_error, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.error_share -> { + onClickedShare() + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun onClickedShare() { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra(Intent.EXTRA_SUBJECT, createErrorTitle()) + intent.putExtra(Intent.EXTRA_TEXT, binding.textViewError.text) + intent.type = "text/plain" + startActivity(intent) + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/EtmActivity.kt b/app/src/main/java/com/nextcloud/client/etm/EtmActivity.kt new file mode 100644 index 000000000000..9e3f41039989 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/EtmActivity.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import androidx.activity.OnBackPressedCallback +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.di.ViewModelFactory +import com.owncloud.android.R +import com.owncloud.android.ui.activity.ToolbarActivity +import javax.inject.Inject + +class EtmActivity : + ToolbarActivity(), + Injectable { + + companion object { + @JvmStatic + fun launch(context: Context) { + val etmIntent = Intent(context, EtmActivity::class.java) + context.startActivity(etmIntent) + } + } + + @Inject + lateinit var viewModelFactory: ViewModelFactory + internal lateinit var vm: EtmViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_etm) + setupToolbar() + updateActionBarTitleAndHomeButtonByString(getString(R.string.etm_title)) + vm = ViewModelProvider(this, viewModelFactory).get(EtmViewModel::class.java) + vm.currentPage.observe( + this, + Observer { + onPageChanged(it) + } + ) + handleOnBackPressed() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + if (!vm.onBackPressed()) { + finish() + } + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun handleOnBackPressed() { + onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val handledByVm = vm.onBackPressed() + + if (!handledByVm) { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + }) + } + + private fun onPageChanged(page: EtmMenuEntry?) { + if (page != null) { + val fragment = page.pageClass.java.getConstructor().newInstance() + supportFragmentManager.beginTransaction() + .replace(R.id.etm_page_container, fragment) + .commit() + updateActionBarTitleAndHomeButtonByString("ETM - ${getString(page.titleRes)}") + } else { + supportFragmentManager.beginTransaction() + .replace(R.id.etm_page_container, EtmMenuFragment()) + .commitNow() + updateActionBarTitleAndHomeButtonByString(getString(R.string.etm_title)) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/EtmBaseFragment.kt b/app/src/main/java/com/nextcloud/client/etm/EtmBaseFragment.kt new file mode 100644 index 000000000000..aac548811c34 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/EtmBaseFragment.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm + +import androidx.fragment.app.Fragment + +abstract class EtmBaseFragment : Fragment() { + protected val vm: EtmViewModel get() { + return (activity as EtmActivity).vm + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt b/app/src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt new file mode 100644 index 000000000000..513ed7445431 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R + +class EtmMenuAdapter(context: Context, val onItemClicked: (Int) -> Unit) : + RecyclerView.Adapter() { + + private val layoutInflater = LayoutInflater.from(context) + var pages: List = listOf() + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value + notifyDataSetChanged() + } + + class PageViewHolder(view: View, onClick: (Int) -> Unit) : RecyclerView.ViewHolder(view) { + val primaryAction: ImageView = view.findViewById(R.id.primary_action) + val text: TextView = view.findViewById(R.id.text) + val secondaryAction: ImageView = view.findViewById(R.id.secondary_action) + + init { + itemView.setOnClickListener { onClick(adapterPosition) } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder { + val view = layoutInflater.inflate(R.layout.material_list_item_single_line, parent, false) + return PageViewHolder(view, onItemClicked) + } + + override fun onBindViewHolder(holder: PageViewHolder, position: Int) { + val page = pages[position] + holder.primaryAction.setImageResource(page.iconRes) + holder.text.setText(page.titleRes) + holder.secondaryAction.setImageResource(0) + } + + override fun getItemCount(): Int = pages.size +} diff --git a/app/src/main/java/com/nextcloud/client/etm/EtmMenuEntry.kt b/app/src/main/java/com/nextcloud/client/etm/EtmMenuEntry.kt new file mode 100644 index 000000000000..6d4ff083866c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/EtmMenuEntry.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm + +import androidx.fragment.app.Fragment +import kotlin.reflect.KClass + +data class EtmMenuEntry(val iconRes: Int, val titleRes: Int, val pageClass: KClass) diff --git a/app/src/main/java/com/nextcloud/client/etm/EtmMenuFragment.kt b/app/src/main/java/com/nextcloud/client/etm/EtmMenuFragment.kt new file mode 100644 index 000000000000..ace0f999491b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/EtmMenuFragment.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R + +class EtmMenuFragment : EtmBaseFragment() { + + private lateinit var adapter: EtmMenuAdapter + private lateinit var list: RecyclerView + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + adapter = EtmMenuAdapter(requireContext(), this::onClickedItem) + adapter.pages = vm.pages + val view = inflater.inflate(R.layout.fragment_etm_menu, container, false) + list = view.findViewById(R.id.etm_menu_list) + list.layoutManager = LinearLayoutManager(requireContext()) + list.adapter = adapter + return view + } + + override fun onResume() { + super.onResume() + activity?.setTitle(R.string.etm_title) + } + + private fun onClickedItem(position: Int) { + vm.onPageSelected(position) + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt b/app/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt new file mode 100644 index 000000000000..6007973869ec --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt @@ -0,0 +1,180 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm + +import android.accounts.Account +import android.accounts.AccountManager +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Resources +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.etm.pages.EtmAccountsFragment +import com.nextcloud.client.etm.pages.EtmBackgroundJobsFragment +import com.nextcloud.client.etm.pages.EtmFileTransferFragment +import com.nextcloud.client.etm.pages.EtmMigrations +import com.nextcloud.client.etm.pages.EtmPreferencesFragment +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.JobInfo +import com.nextcloud.client.jobs.transfer.TransferManagerConnection +import com.nextcloud.client.migrations.MigrationInfo +import com.nextcloud.client.migrations.MigrationsDb +import com.nextcloud.client.migrations.MigrationsManager +import com.owncloud.android.R +import com.owncloud.android.lib.common.accounts.AccountUtils +import javax.inject.Inject + +@Suppress("LongParameterList") // Dependencies Injection +@SuppressLint("StaticFieldLeak") +class EtmViewModel @Inject constructor( + private val context: Context, + private val defaultPreferences: SharedPreferences, + private val platformAccountManager: AccountManager, + private val accountManager: UserAccountManager, + private val resources: Resources, + private val backgroundJobManager: BackgroundJobManager, + private val migrationsManager: MigrationsManager, + private val migrationsDb: MigrationsDb +) : ViewModel() { + + companion object { + val ACCOUNT_USER_DATA_KEYS = listOf( + // AccountUtils.Constants.KEY_COOKIES, is disabled + AccountUtils.Constants.KEY_DISPLAY_NAME, + AccountUtils.Constants.KEY_OC_ACCOUNT_VERSION, + AccountUtils.Constants.KEY_OC_BASE_URL, + AccountUtils.Constants.KEY_OC_VERSION, + AccountUtils.Constants.KEY_USER_ID + ) + + const val PAGE_SETTINGS = 0 + const val PAGE_ACCOUNTS = 1 + const val PAGE_JOBS = 2 + const val PAGE_MIGRATIONS = 3 + } + + /** + * This data class holds all relevant account information that is + * otherwise kept in separate collections. + */ + data class AccountData(val account: Account, val userData: Map) + + val currentUser: User get() = accountManager.user + val currentPage: LiveData = MutableLiveData() + val pages: List = listOf( + EtmMenuEntry( + iconRes = R.drawable.ic_settings, + titleRes = R.string.etm_preferences, + pageClass = EtmPreferencesFragment::class + ), + EtmMenuEntry( + iconRes = R.drawable.ic_user_outline, + titleRes = R.string.etm_accounts, + pageClass = EtmAccountsFragment::class + ), + EtmMenuEntry( + iconRes = R.drawable.ic_clock, + titleRes = R.string.etm_background_jobs, + pageClass = EtmBackgroundJobsFragment::class + ), + EtmMenuEntry( + iconRes = R.drawable.ic_arrow_up, + titleRes = R.string.etm_migrations, + pageClass = EtmMigrations::class + ), + EtmMenuEntry( + iconRes = R.drawable.ic_cloud_download, + titleRes = R.string.etm_transfer, + pageClass = EtmFileTransferFragment::class + ) + ) + val transferManagerConnection = TransferManagerConnection(context, accountManager.user) + + val preferences: Map get() { + return defaultPreferences.all + .map { it.key to "${it.value}" } + .sortedBy { it.first } + .toMap() + } + + val accounts: List get() { + val accountType = resources.getString(R.string.account_type) + return platformAccountManager.getAccountsByType(accountType).map { account -> + val userData: Map = ACCOUNT_USER_DATA_KEYS.map { key -> + key to platformAccountManager.getUserData(account, key) + }.toMap() + AccountData(account, userData) + } + } + + val backgroundJobs: LiveData> get() { + return backgroundJobManager.jobs + } + + val migrationsInfo: List get() { + return migrationsManager.info + } + + val migrationsStatus: MigrationsManager.Status get() { + return migrationsManager.status.value ?: MigrationsManager.Status.UNKNOWN + } + + val lastMigratedVersion: Int get() { + return migrationsDb.lastMigratedVersion + } + + init { + (currentPage as MutableLiveData).apply { + value = null + } + } + + fun onPageSelected(index: Int) { + if (index < pages.size) { + currentPage as MutableLiveData + currentPage.value = pages[index] + } + } + + fun onBackPressed(): Boolean { + (currentPage as MutableLiveData) + return if (currentPage.value != null) { + currentPage.value = null + true + } else { + false + } + } + + fun pruneJobs() { + backgroundJobManager.pruneJobs() + } + + fun cancelAllJobs() { + backgroundJobManager.cancelAllJobs() + } + + fun startTestJob(periodic: Boolean) { + if (periodic) { + backgroundJobManager.scheduleTestJob() + } else { + backgroundJobManager.startImmediateTestJob() + } + } + + fun cancelTestJob() { + backgroundJobManager.cancelTestJob() + } + + fun clearMigrations() { + migrationsDb.clearMigrations() + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/pages/EtmAccountsFragment.kt b/app/src/main/java/com/nextcloud/client/etm/pages/EtmAccountsFragment.kt new file mode 100644 index 000000000000..3d788f89ad0d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/pages/EtmAccountsFragment.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm.pages + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import com.nextcloud.client.etm.EtmBaseFragment +import com.owncloud.android.R +import com.owncloud.android.databinding.FragmentEtmAccountsBinding + +class EtmAccountsFragment : EtmBaseFragment() { + private var _binding: FragmentEtmAccountsBinding? = null + val binding get() = _binding!! + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + _binding = FragmentEtmAccountsBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onResume() { + super.onResume() + val builder = StringBuilder() + vm.accounts.forEach { + builder.append("Account: ${it.account.name}\n") + it.userData.forEach { + builder.append("\t${it.key}: ${it.value}\n") + } + } + binding.etmAccountsText.text = builder.toString() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.fragment_etm_accounts, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.etm_accounts_share -> { + onClickedShare() + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun onClickedShare() { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra(Intent.EXTRA_SUBJECT, "Nextcloud accounts information") + intent.putExtra(Intent.EXTRA_TEXT, binding.etmAccountsText.text) + intent.type = "text/plain" + startActivity(intent) + } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/pages/EtmBackgroundJobsFragment.kt b/app/src/main/java/com/nextcloud/client/etm/pages/EtmBackgroundJobsFragment.kt new file mode 100644 index 000000000000..a86dc38aec42 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/pages/EtmBackgroundJobsFragment.kt @@ -0,0 +1,208 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm.pages + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.etm.EtmBaseFragment +import com.nextcloud.client.jobs.BackgroundJobManagerImpl +import com.nextcloud.client.jobs.JobInfo +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.R +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject + +class EtmBackgroundJobsFragment : + EtmBaseFragment(), + Injectable { + + @Inject + lateinit var preferences: AppPreferences + + class Adapter(private val inflater: LayoutInflater, private val preferences: AppPreferences) : + RecyclerView.Adapter() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val uuid = view.findViewById(R.id.etm_background_job_uuid) + val name = view.findViewById(R.id.etm_background_job_name) + val user = view.findViewById(R.id.etm_background_job_user) + val state = view.findViewById(R.id.etm_background_job_state) + val started = view.findViewById(R.id.etm_background_job_started) + val progress = view.findViewById(R.id.etm_background_job_progress) + private val progressRow = view.findViewById(R.id.etm_background_job_progress_row) + val executionCount = view.findViewById(R.id.etm_background_execution_count) + val executionLog = view.findViewById(R.id.etm_background_execution_logs) + private val executionLogRow = view.findViewById(R.id.etm_background_execution_logs_row) + val executionTimesRow = view.findViewById(R.id.etm_background_execution_times_row) + + var progressEnabled: Boolean = progressRow.isVisible + get() { + return progressRow.isVisible + } + set(value) { + field = value + progressRow.visibility = if (value) { + View.VISIBLE + } else { + View.GONE + } + } + + var logsEnabled: Boolean = executionLogRow.isVisible + get() { + return executionLogRow.isVisible + } + set(value) { + field = value + executionLogRow.visibility = if (value) { + View.VISIBLE + } else { + View.GONE + } + } + } + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:MM:ssZ", Locale.getDefault()) + var backgroundJobs: List = emptyList() + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = inflater.inflate(R.layout.etm_background_job_list_item, parent, false) + val viewHolder = ViewHolder(view) + viewHolder.logsEnabled = false + viewHolder.executionTimesRow.visibility = View.GONE + view.setOnClickListener { + viewHolder.logsEnabled = !viewHolder.logsEnabled + } + return viewHolder + } + + override fun getItemCount(): Int = backgroundJobs.size + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(vh: ViewHolder, position: Int) { + val info = backgroundJobs[position] + vh.uuid.text = info.id.toString() + vh.name.text = info.name + vh.user.text = info.user + vh.state.text = info.state + vh.started.text = dateFormat.format(info.started) + if (info.progress >= 0) { + vh.progressEnabled = true + vh.progress.text = info.progress.toString() + } else { + vh.progressEnabled = false + } + + val logs = preferences.readLogEntry() + val logsForThisWorker = + logs.filter { BackgroundJobManagerImpl.parseTag(it.workerClass)?.second == info.workerClass } + if (logsForThisWorker.isNotEmpty()) { + vh.executionTimesRow.visibility = View.VISIBLE + vh.executionCount.text = + "${logsForThisWorker.filter { it.started != null }.size} " + + "(${logsForThisWorker.filter { it.finished != null }.size})" + var logText = "Worker Logs\n\n" + + "*** Does NOT differentiate between immediate or periodic kinds of Work! ***\n" + + "*** Times run in 48h: Times started (Times finished) ***\n" + logsForThisWorker.forEach { + logText += "----------------------\n" + logText += "Worker ${BackgroundJobManagerImpl.parseTag(it.workerClass)?.second}\n" + logText += if (it.started == null) { + "ENDED at\n${it.finished}\nWith result: ${it.result}\n" + } else { + "STARTED at\n${it.started}\n" + } + } + vh.executionLog.text = logText + } else { + vh.executionLog.text = "Worker Logs\n\n" + + "No Entries -> Maybe logging is not implemented for Worker or it has not run yet." + vh.executionCount.text = "0" + vh.executionTimesRow.visibility = View.GONE + } + } + } + + private lateinit var list: RecyclerView + private lateinit var adapter: Adapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_etm_background_jobs, container, false) + adapter = Adapter(inflater, preferences) + list = view.findViewById(R.id.etm_background_jobs_list) + list.layoutManager = LinearLayoutManager(context) + list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + list.adapter = adapter + vm.backgroundJobs.observe(viewLifecycleOwner, Observer { onBackgroundJobsUpdated(it) }) + return view + } + + @Deprecated("Deprecated in Java") + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.fragment_etm_background_jobs, menu) + } + + @Deprecated("Deprecated in Java") + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.etm_background_jobs_cancel -> { + vm.cancelAllJobs() + true + } + + R.id.etm_background_jobs_prune -> { + vm.pruneJobs() + true + } + + R.id.etm_background_jobs_start_test -> { + vm.startTestJob(periodic = false) + true + } + + R.id.etm_background_jobs_schedule_test -> { + vm.startTestJob(periodic = true) + true + } + + R.id.etm_background_jobs_cancel_test -> { + vm.cancelTestJob() + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun onBackgroundJobsUpdated(backgroundJobs: List) { + adapter.backgroundJobs = backgroundJobs + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/pages/EtmFileTransferFragment.kt b/app/src/main/java/com/nextcloud/client/etm/pages/EtmFileTransferFragment.kt new file mode 100644 index 000000000000..5c45a6bdb2eb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/pages/EtmFileTransferFragment.kt @@ -0,0 +1,177 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm.pages + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.client.etm.EtmBaseFragment +import com.nextcloud.client.files.DownloadRequest +import com.nextcloud.client.files.UploadRequest +import com.nextcloud.client.jobs.transfer.Transfer +import com.nextcloud.client.jobs.transfer.TransferManager +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload +import java.util.Locale + +class EtmFileTransferFragment : EtmBaseFragment() { + + companion object { + private const val TEST_DOWNLOAD_DUMMY_PATH = "/test/dummy_file.txt" + } + + class Adapter(private val inflater: LayoutInflater) : RecyclerView.Adapter() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val type = view.findViewById(R.id.etm_transfer_type) + val typeIcon = view.findViewById(R.id.etm_transfer_type_icon) + val uuid = view.findViewById(R.id.etm_transfer_uuid) + val path = view.findViewById(R.id.etm_transfer_remote_path) + val user = view.findViewById(R.id.etm_transfer_user) + val state = view.findViewById(R.id.etm_transfer_state) + val progress = view.findViewById(R.id.etm_transfer_progress) + private val progressRow = view.findViewById(R.id.etm_transfer_progress_row) + + var progressEnabled: Boolean = progressRow.isVisible + get() { + return progressRow.isVisible + } + set(value) { + field = value + progressRow.visibility = if (value) { + View.VISIBLE + } else { + View.GONE + } + } + } + + private var transfers = listOf() + + @SuppressLint("NotifyDataSetChanged") + fun setStatus(status: TransferManager.Status) { + transfers = listOf(status.pending, status.running, status.completed).flatten().reversed() + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = inflater.inflate(R.layout.etm_transfer_list_item, parent, false) + return ViewHolder(view) + } + + override fun getItemCount(): Int = transfers.size + + override fun onBindViewHolder(vh: ViewHolder, position: Int) { + val transfer = transfers[position] + + val transferTypeStrId = when (transfer.request) { + is DownloadRequest -> R.string.etm_transfer_type_download + is UploadRequest -> R.string.etm_transfer_type_upload + } + + val transferTypeIconId = when (transfer.request) { + is DownloadRequest -> R.drawable.ic_cloud_download + is UploadRequest -> R.drawable.ic_cloud_upload + } + + vh.type.setText(transferTypeStrId) + vh.typeIcon.setImageResource(transferTypeIconId) + vh.uuid.text = transfer.uuid.toString() + vh.path.text = transfer.request.file.remotePath + vh.user.text = transfer.request.user.accountName + vh.state.text = transfer.state.toString() + if (transfer.progress >= 0) { + vh.progressEnabled = true + vh.progress.text = String.format(Locale.getDefault(), "%d", transfer.progress) + } else { + vh.progressEnabled = false + } + } + } + + private lateinit var adapter: Adapter + private lateinit var list: RecyclerView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_etm_downloader, container, false) + adapter = Adapter(inflater) + list = view.findViewById(R.id.etm_download_list) + list.layoutManager = LinearLayoutManager(context) + list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + list.adapter = adapter + return view + } + + override fun onResume() { + super.onResume() + vm.transferManagerConnection.bind() + vm.transferManagerConnection.registerStatusListener(this::onDownloaderStatusChanged) + } + + override fun onPause() { + super.onPause() + vm.transferManagerConnection.unbind() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.fragment_etm_file_transfer, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.etm_test_download -> { + scheduleTestDownload() + true + } + + R.id.etm_test_upload -> { + scheduleTestUpload() + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun scheduleTestDownload() { + val request = DownloadRequest( + vm.currentUser, + OCFile(TEST_DOWNLOAD_DUMMY_PATH), + true + ) + vm.transferManagerConnection.enqueue(request) + } + + private fun scheduleTestUpload() { + val request = UploadRequest( + vm.currentUser, + OCUpload(TEST_DOWNLOAD_DUMMY_PATH, TEST_DOWNLOAD_DUMMY_PATH, vm.currentUser.accountName), + true + ) + vm.transferManagerConnection.enqueue(request) + } + + private fun onDownloaderStatusChanged(status: TransferManager.Status) { + adapter.setStatus(status) + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/pages/EtmMigrations.kt b/app/src/main/java/com/nextcloud/client/etm/pages/EtmMigrations.kt new file mode 100644 index 000000000000..926f949273fa --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/pages/EtmMigrations.kt @@ -0,0 +1,87 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm.pages + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import com.nextcloud.client.etm.EtmBaseFragment +import com.owncloud.android.R +import com.owncloud.android.databinding.FragmentEtmMigrationsBinding +import java.util.Locale + +class EtmMigrations : EtmBaseFragment() { + private var _binding: FragmentEtmMigrationsBinding? = null + val binding get() = _binding!! + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + _binding = FragmentEtmMigrationsBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onResume() { + super.onResume() + showStatus() + } + + fun showStatus() { + val builder = StringBuilder() + val status = vm.migrationsStatus.toString().lowercase(Locale.US) + builder.append("Migration status: $status\n") + val lastMigratedVersion = if (vm.lastMigratedVersion >= 0) { + vm.lastMigratedVersion.toString() + } else { + "never" + } + builder.append("Last migrated version: $lastMigratedVersion\n") + builder.append("Migrations:\n") + vm.migrationsInfo.forEach { + val migrationStatus = if (it.applied) { + "applied" + } else { + "pending" + } + builder.append(" - ${it.id} ${it.description} - $migrationStatus\n") + } + binding.etmMigrationsText.text = builder.toString() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.fragment_etm_migrations, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.etm_migrations_delete -> { + onDeleteMigrationsClicked() + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun onDeleteMigrationsClicked() { + vm.clearMigrations() + showStatus() + } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } +} diff --git a/app/src/main/java/com/nextcloud/client/etm/pages/EtmPreferencesFragment.kt b/app/src/main/java/com/nextcloud/client/etm/pages/EtmPreferencesFragment.kt new file mode 100644 index 000000000000..3061c8df997b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/etm/pages/EtmPreferencesFragment.kt @@ -0,0 +1,70 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.etm.pages + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import com.nextcloud.client.etm.EtmBaseFragment +import com.owncloud.android.R +import com.owncloud.android.databinding.FragmentEtmPreferencesBinding + +class EtmPreferencesFragment : EtmBaseFragment() { + private var _binding: FragmentEtmPreferencesBinding? = null + val binding get() = _binding!! + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + _binding = FragmentEtmPreferencesBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onResume() { + super.onResume() + val builder = StringBuilder() + vm.preferences.forEach { builder.append("${it.key}: ${it.value}\n") } + binding.etmPreferencesText.text = builder + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.fragment_etm_preferences, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.etm_preferences_share -> { + onClickedShare() + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun onClickedShare() { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra(Intent.EXTRA_SUBJECT, "Nextcloud preferences") + intent.putExtra(Intent.EXTRA_TEXT, binding.etmPreferencesText.text) + intent.type = "text/plain" + startActivity(intent) + } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } +} diff --git a/app/src/main/java/com/nextcloud/client/files/DeepLinkConstants.kt b/app/src/main/java/com/nextcloud/client/files/DeepLinkConstants.kt new file mode 100644 index 000000000000..0ad984208223 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/files/DeepLinkConstants.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.files + +import com.owncloud.android.R + +enum class DeepLinkConstants(val route: String, val navId: Int) { + OPEN_FILES("openFiles", R.id.nav_all_files), + OPEN_FAVORITES("openFavorites", R.id.nav_favorites), + OPEN_MEDIA("openMedia", R.id.nav_gallery), + OPEN_SHARED("openShared", R.id.nav_shared), + OPEN_OFFLINE("openOffline", R.id.nav_on_device), + OPEN_NOTIFICATIONS("openNotifications", -1), + OPEN_DELETED("openDeleted", R.id.nav_trashbin), + OPEN_SETTINGS("openSettings", R.id.nav_settings), + + // Special case, handled separately + OPEN_AUTO_UPLOAD("openAutoUpload", -1), + OPEN_EXTERNAL_URL("openUrl", -1), + ACTION_CREATE_NEW("createNew", -1), + ACTION_APP_UPDATE("checkAppUpdate", -1); + + companion object { + fun fromPath(path: String?): DeepLinkConstants? = entries.find { it.route == path } + } +} diff --git a/app/src/main/java/com/nextcloud/client/files/DeepLinkHandler.kt b/app/src/main/java/com/nextcloud/client/files/DeepLinkHandler.kt new file mode 100644 index 000000000000..a7cc8114dc7f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/files/DeepLinkHandler.kt @@ -0,0 +1,57 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files + +import android.net.Uri +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager + +/** + * This component parses and matches deep links. + * Result is returned to the UI for further processing. + * + * TODO: This is intermediate refactoring step; this component should be moved into + * [com.nextcloud.client.mixins.ActivityMixin] and handle UI callbacks as well + */ +@Suppress("ForbiddenComment") +class DeepLinkHandler(private val userAccountManager: UserAccountManager) { + + /** + * Provide parsed link arguments and context information required + * to launch it. + */ + data class Match(val users: List, val fileId: String) + + companion object { + val DEEP_LINK_PATTERN = Regex("""(.*?)(/index\.php)?/f/([0-9]+)$""") + const val BASE_URL_GROUP_INDEX = 1 + const val INDEX_PATH_GROUP_INDEX = 2 + const val FILE_ID_GROUP_INDEX = 3 + } + + /** + * Parse deep link and return a match result. + * Matching result may depend on environmental factors, such + * as app version or registered users. + * + * @param uri Deep link as arrived in incoming [android.content.Intent] + * @return deep link match result with all context data required for further processing; null if link does not match + */ + fun parseDeepLink(uri: Uri): Match? { + val match = DEEP_LINK_PATTERN.matchEntire(uri.toString()) + if (match != null) { + val baseServerUrl = match.groupValues[BASE_URL_GROUP_INDEX] + val fileId = match.groupValues[FILE_ID_GROUP_INDEX] + return Match(users = getMatchingUsers(baseServerUrl), fileId = fileId) + } else { + return null + } + } + + private fun getMatchingUsers(serverBaseUrl: String): List = + userAccountManager.allUsers.filter { it.server.uri.toString() == serverBaseUrl } +} diff --git a/app/src/main/java/com/nextcloud/client/files/Direction.kt b/app/src/main/java/com/nextcloud/client/files/Direction.kt new file mode 100644 index 000000000000..d84987addfc9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/files/Direction.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files + +enum class Direction { + DOWNLOAD, + UPLOAD +} diff --git a/app/src/main/java/com/nextcloud/client/files/Registry.kt b/app/src/main/java/com/nextcloud/client/files/Registry.kt new file mode 100644 index 000000000000..9e75643df870 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/files/Registry.kt @@ -0,0 +1,148 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files + +import com.nextcloud.client.jobs.transfer.Transfer +import com.nextcloud.client.jobs.transfer.TransferState +import com.owncloud.android.datamodel.OCFile +import java.util.UUID +import kotlin.math.max +import kotlin.math.min + +/** + * This class tracks status of file transfers. It serves as a state + * machine and drives the transfer background task scheduler via callbacks. + * Transfer status updates trigger change callbacks that should be used + * to notify listeners. + * + * No listener registration mechanism is provided at this level. + * + * This class is not thread-safe. All access from multiple threads shall + * be lock protected. + * + * @property onStartTransfer callback triggered when transfer is switched into running state + * @property onTransferChanged callback triggered whenever transfer status update + * @property maxRunning maximum number of allowed simultaneous transfers + */ +internal class Registry( + private val onStartTransfer: (UUID, Request) -> Unit, + private val onTransferChanged: (Transfer) -> Unit, + private val maxRunning: Int = 2 +) { + private val pendingQueue = LinkedHashMap() + private val runningQueue = LinkedHashMap() + private val completedQueue = LinkedHashMap() + + val isRunning: Boolean get() = pendingQueue.size > 0 || runningQueue.size > 0 + + val pending: List get() = pendingQueue.map { it.value } + val running: List get() = runningQueue.map { it.value } + val completed: List get() = completedQueue.map { it.value } + + /** + * Insert new transfer into a pending queue. + * + * @return scheduled transfer id + */ + fun add(request: Request): UUID { + val transfer = Transfer( + uuid = request.uuid, + state = TransferState.PENDING, + progress = 0, + file = request.file, + request = request + ) + pendingQueue[transfer.uuid] = transfer + return transfer.uuid + } + + /** + * Move pending transfers into a running queue up + * to max allowed simultaneous transfers. + */ + fun startNext() { + val freeThreads = max(0, maxRunning - runningQueue.size) + for (i in 0 until min(freeThreads, pendingQueue.size)) { + val key = pendingQueue.keys.first() + val pendingTransfer = pendingQueue.remove(key) ?: throw IllegalStateException("Transfer $key not found") + val runningTransfer = pendingTransfer.copy(state = TransferState.RUNNING) + runningQueue[key] = runningTransfer + onStartTransfer.invoke(key, runningTransfer.request) + onTransferChanged(runningTransfer) + } + } + + /** + * Update progress for a given transfer. If no transfer of a given id is currently running, + * update is ignored. + * + * @param uuid ID of the transfer to update + * @param progress progress 0-100% + */ + fun progress(uuid: UUID, progress: Int) { + val transfer = runningQueue[uuid] + if (transfer != null) { + val runningTransfer = transfer.copy(progress = progress) + runningQueue[uuid] = runningTransfer + onTransferChanged(runningTransfer) + } + } + + /** + * Complete currently running transfer. If no transfer of a given id is currently running, + * update is ignored. + * + * @param uuid of the transfer to complete + * @param success if true, transfer will be marked as completed; if false - as failed + * @param file if provided, update file in transfer status; if null, existing value is retained + */ + fun complete(uuid: UUID, success: Boolean, file: OCFile? = null) { + val transfer = runningQueue.remove(uuid) + if (transfer != null) { + val status = if (success) { + TransferState.COMPLETED + } else { + TransferState.FAILED + } + val completedTransfer = transfer.copy(state = status, file = file ?: transfer.file) + completedQueue[uuid] = completedTransfer + onTransferChanged(completedTransfer) + } + } + + /** + * Search for a transfer by file path. It traverses + * through all queues in order of pending, running and completed + * transfers and returns first transfer status matching + * file path. + * + * @param file Search for a file transfer + * @return transfer status if found, null otherwise + */ + fun getTransfer(file: OCFile): Transfer? { + arrayOf(pendingQueue, runningQueue, completedQueue).forEach { queue -> + queue.forEach { entry -> + if (entry.value.request.file.remotePath == file.remotePath) { + return entry.value + } + } + } + return null + } + + /** + * Get transfer status by id. It traverses + * through all queues in order of pending, running and completed + * transfers and returns first transfer status matching + * file path. + * + * @param id transfer id + * @return transfer status if found, null otherwise + */ + fun getTransfer(uuid: UUID): Transfer? = pendingQueue[uuid] ?: runningQueue[uuid] ?: completedQueue[uuid] +} diff --git a/app/src/main/java/com/nextcloud/client/files/Request.kt b/app/src/main/java/com/nextcloud/client/files/Request.kt new file mode 100644 index 000000000000..cc72bcecaf4a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/files/Request.kt @@ -0,0 +1,204 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.files + +import android.os.Parcel +import android.os.Parcelable +import com.nextcloud.client.account.User +import com.nextcloud.client.jobs.upload.PostUploadAction +import com.nextcloud.client.jobs.upload.UploadTrigger +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import java.util.UUID + +sealed class Request(val user: User, val file: OCFile, val uuid: UUID, val type: Direction, val test: Boolean) : + Parcelable + +/** + * Transfer request. This class should collect all information + * required to trigger transfer operation. + * + * Class is immutable by design, although [OCFile] or other + * types might not be immutable. Clients should no modify + * contents of this object. + * + * @property user Transfer will be triggered for a given user + * @property file File to transfer + * @property uuid Unique request identifier; this identifier can be set in [Transfer] + * @property dummy if true, this requests a dummy test transfer; no real file transfer will occur + */ +class DownloadRequest internal constructor( + user: User, + file: OCFile, + uuid: UUID, + type: Direction, + test: Boolean = false +) : Request(user, file, uuid, type, test) { + + constructor( + user: User, + file: OCFile + ) : this(user, file, UUID.randomUUID(), Direction.DOWNLOAD) + + constructor( + user: User, + file: OCFile, + test: Boolean + ) : this(user, file, UUID.randomUUID(), Direction.DOWNLOAD, test) + + constructor(parcel: Parcel) : this( + user = parcel.readParcelable(User::class.java.classLoader) as User, + file = parcel.readParcelable(OCFile::class.java.classLoader) as OCFile, + uuid = parcel.readSerializable() as UUID, + type = parcel.readSerializable() as Direction, + test = parcel.readInt() != 0 + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(user, flags) + parcel.writeParcelable(file, flags) + parcel.writeSerializable(uuid) + parcel.writeSerializable(type) + parcel.writeInt(if (test) 1 else 0) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): DownloadRequest = DownloadRequest(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } +} + +@Suppress("LongParameterList") +class UploadRequest internal constructor( + user: User, + file: OCFile, + val upload: OCUpload, + uuid: UUID, + type: Direction, + test: Boolean +) : Request(user, file, uuid, type, test) { + + constructor( + user: User, + upload: OCUpload, + test: Boolean + ) : this( + user, + OCFile(upload.remotePath).apply { + storagePath = upload.localPath + fileLength = upload.fileSize + }, + upload, + UUID.randomUUID(), + Direction.UPLOAD, + test + ) + + constructor( + user: User, + upload: OCUpload + ) : this(user, upload, false) + + constructor(parcel: Parcel) : this( + user = parcel.readParcelable(User::class.java.classLoader) as User, + file = parcel.readParcelable(OCFile::class.java.classLoader) as OCFile, + upload = parcel.readParcelable(OCUpload::class.java.classLoader) as OCUpload, + uuid = parcel.readSerializable() as UUID, + type = parcel.readSerializable() as Direction, + test = parcel.readInt() != 0 + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(user, flags) + parcel.writeParcelable(file, flags) + parcel.writeParcelable(upload, flags) + parcel.writeSerializable(uuid) + parcel.writeSerializable(type) + parcel.writeInt(if (test) 1 else 0) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): UploadRequest = UploadRequest(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + + /** + * This class provides a builder pattern with API convenient to be used in Java. + */ + class Builder(private val user: User, private var source: String, private var destination: String) { + private var fileSize: Long = 0 + private var nameConflictPolicy = NameCollisionPolicy.ASK_USER + private var createRemoteFolder = true + private var trigger = UploadTrigger.USER + private var requireWifi = false + private var requireCharging = false + private var postUploadAction = PostUploadAction.NONE + + fun setPaths(source: String, destination: String): Builder { + this.source = source + this.destination = destination + return this + } + + fun setFileSize(fileSize: Long): Builder { + this.fileSize = fileSize + return this + } + + fun setNameConflicPolicy(policy: NameCollisionPolicy): Builder { + this.nameConflictPolicy = policy + return this + } + + fun setCreateRemoteFolder(create: Boolean): Builder { + this.createRemoteFolder = create + return this + } + + fun setTrigger(trigger: UploadTrigger): Builder { + this.trigger = trigger + return this + } + + fun setRequireWifi(require: Boolean): Builder { + this.requireWifi = require + return this + } + + fun setRequireCharging(require: Boolean): Builder { + this.requireCharging = require + return this + } + + fun setPostAction(action: PostUploadAction): Builder { + this.postUploadAction = action + return this + } + + fun build(): Request { + val upload = OCUpload(source, destination, user.accountName) + upload.fileSize = fileSize + upload.nameCollisionPolicy = this.nameConflictPolicy + upload.isCreateRemoteFolder = this.createRemoteFolder + upload.createdBy = this.trigger.value + upload.localAction = this.postUploadAction.value + upload.isUseWifiOnly = this.requireWifi + upload.isWhileChargingOnly = this.requireCharging + upload.uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS + return UploadRequest(user, upload) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/integrations/IntegrationsModule.kt b/app/src/main/java/com/nextcloud/client/integrations/IntegrationsModule.kt new file mode 100644 index 000000000000..9a77053a0581 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/integrations/IntegrationsModule.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.integrations + +import android.content.Context +import android.content.pm.PackageManager +import com.nextcloud.client.integrations.deck.DeckApi +import com.nextcloud.client.integrations.deck.DeckApiImpl +import dagger.Module +import dagger.Provides + +@Module +class IntegrationsModule { + @Provides + fun deckApi(context: Context, packageManager: PackageManager): DeckApi = DeckApiImpl(context, packageManager) +} diff --git a/app/src/main/java/com/nextcloud/client/integrations/deck/DeckApi.kt b/app/src/main/java/com/nextcloud/client/integrations/deck/DeckApi.kt new file mode 100644 index 000000000000..b956e4cf6cc6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/integrations/deck/DeckApi.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Stefan Niedermann + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.client.integrations.deck + +import android.app.PendingIntent +import com.nextcloud.client.account.User +import com.owncloud.android.lib.resources.notifications.models.Notification +import java.util.Optional + +/** + * This API is for an integration with the [Nextcloud + * Deck](https://github.com/stefan-niedermann/nextcloud-deck) app for android. + */ +interface DeckApi { + /** + * Creates a PendingIntent that can be used in a NotificationBuilder to open the notification link in Deck app + * + * @param notification Notification Notification that could be forwarded to Deck + * @param user The user that is affected by the notification + * @return If notification can be consumed by Deck, a PendingIntent opening notification link in Deck app; empty + * value otherwise + * @see [Deck Server App](https://apps.nextcloud.com/apps/deck) + */ + fun createForwardToDeckActionIntent(notification: Notification, user: User): Optional +} diff --git a/app/src/main/java/com/nextcloud/client/integrations/deck/DeckApiImpl.kt b/app/src/main/java/com/nextcloud/client/integrations/deck/DeckApiImpl.kt new file mode 100644 index 000000000000..82b13944f36b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/integrations/deck/DeckApiImpl.kt @@ -0,0 +1,70 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Stefan Niedermann + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.client.integrations.deck + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import com.nextcloud.client.account.User +import com.owncloud.android.lib.resources.notifications.models.Notification +import java.util.Optional + +class DeckApiImpl(private val context: Context, private val packageManager: PackageManager) : DeckApi { + override fun createForwardToDeckActionIntent(notification: Notification, user: User): Optional { + if (APP_NAME.equals(notification.app, ignoreCase = true)) { + val intent = Intent() + for (appPackage in DECK_APP_PACKAGES) { + intent.setClassName(appPackage, DECK_ACTIVITY_TO_START) + if (packageManager.resolveActivity(intent, 0) != null) { + return Optional.of(createPendingIntent(intent, notification, user)) + } + } + } + return Optional.empty() + } + + private fun createPendingIntent(intent: Intent, notification: Notification, user: User): PendingIntent { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return PendingIntent.getActivity( + context, + notification.getNotificationId(), + putExtrasToIntent(intent, notification, user), + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun putExtrasToIntent(intent: Intent, notification: Notification, user: User): Intent = intent + .putExtra(EXTRA_ACCOUNT, user.accountName) + .putExtra(EXTRA_LINK, notification.getLink()) + .putExtra(EXTRA_OBJECT_ID, notification.getObjectId()) + .putExtra(EXTRA_SUBJECT, notification.getSubject()) + .putExtra(EXTRA_SUBJECT_RICH, notification.getSubjectRich()) + .putExtra(EXTRA_MESSAGE, notification.getMessage()) + .putExtra(EXTRA_MESSAGE_RICH, notification.getMessageRich()) + .putExtra(EXTRA_USER, notification.getUser()) + .putExtra(EXTRA_NID, notification.getNotificationId()) + + companion object { + const val APP_NAME = "deck" + val DECK_APP_PACKAGES = arrayOf( + "it.niedermann.nextcloud.deck", + "it.niedermann.nextcloud.deck.play", + "it.niedermann.nextcloud.deck.dev" + ) + const val DECK_ACTIVITY_TO_START = "it.niedermann.nextcloud.deck.ui.PushNotificationActivity" + private const val EXTRA_ACCOUNT = "account" + private const val EXTRA_LINK = "link" + private const val EXTRA_OBJECT_ID = "objectId" + private const val EXTRA_SUBJECT = "subject" + private const val EXTRA_SUBJECT_RICH = "subjectRich" + private const val EXTRA_MESSAGE = "message" + private const val EXTRA_MESSAGE_RICH = "messageRich" + private const val EXTRA_USER = "user" + private const val EXTRA_NID = "nid" + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt new file mode 100644 index 000000000000..ebcb5f7dd4d3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt @@ -0,0 +1,213 @@ +/* +* Nextcloud Android client application +* +* @author Tobias Kaminsky +* @author Chris Narkiewicz +* +* Copyright (C) 2017 Tobias Kaminsky +* Copyright (C) 2017 Nextcloud GmbH. +* Copyright (C) 2020 Chris Narkiewicz +* +* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +*/ +package com.nextcloud.client.jobs + +import android.content.Context +import android.text.TextUtils +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.google.gson.Gson +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.core.Clock +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.FilesystemDataProvider +import com.owncloud.android.datamodel.PushConfigurationState +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.users.DeleteAppPasswordRemoteOperation +import com.owncloud.android.lib.resources.users.RemoteWipeSuccessRemoteOperation +import com.owncloud.android.providers.DocumentsStorageProvider +import com.owncloud.android.ui.activity.ContactsPreferenceActivity +import com.owncloud.android.ui.activity.ManageAccountsActivity +import com.owncloud.android.ui.events.AccountRemovedEvent +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.PushUtils +import org.greenrobot.eventbus.EventBus +import java.util.Optional + +/** + * Removes account and all local files + */ +@Suppress("LongParameterList") // legacy code +class AccountRemovalWork( + private val context: Context, + params: WorkerParameters, + private val uploadsStorageManager: UploadsStorageManager, + private val userAccountManager: UserAccountManager, + private val backgroundJobManager: BackgroundJobManager, + private val clock: Clock, + private val eventBus: EventBus, + private val preferences: AppPreferences, + private val syncedFolderProvider: SyncedFolderProvider +) : Worker(context, params) { + + companion object { + const val TAG = "AccountRemovalJob" + const val ACCOUNT = "account" + const val REMOTE_WIPE = "remote_wipe" + } + + @Suppress("ReturnCount") // legacy code + override fun doWork(): Result { + val accountName = inputData.getString(ACCOUNT) ?: "" + if (TextUtils.isEmpty(accountName)) { + // didn't receive account to delete + return Result.failure() + } + val optionalUser = userAccountManager.getUser(accountName) + if (!optionalUser.isPresent) { + // trying to delete non-existing user + return Result.failure() + } + val remoteWipe = inputData.getBoolean(REMOTE_WIPE, false) + val arbitraryDataProvider: ArbitraryDataProvider = ArbitraryDataProviderImpl(context) + val user = optionalUser.get() + backgroundJobManager.cancelPeriodicContactsBackup(user) + val userRemoved = userAccountManager.removeUser(user) + val storageManager = FileDataStorageManager(user, context.contentResolver) + + // disable daily backup + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP, + "false" + ) + // unregister push notifications + unregisterPushNotifications(context, user, arbitraryDataProvider) + + // remove pending account removal + arbitraryDataProvider.deleteKeyForAccount(user.accountName, ManageAccountsActivity.PENDING_FOR_REMOVAL) + + // remove synced folders set for account + removeSyncedFolders(context, user, clock) + + // delete all uploads for account + uploadsStorageManager.removeUserUploads(user) + + // delete stored E2E keys and mnemonic + EncryptionUtils.removeE2E(arbitraryDataProvider, user) + + // unset default account, if needed + if (preferences.currentAccountName.equals(user.accountName)) { + preferences.currentAccountName = "" + } + + // remove all files + storageManager.removeLocalFiles(user, storageManager) + + // delete all database entries + storageManager.deleteAllFiles() + + if (remoteWipe) { + val optionalClient = createClient(user) + if (optionalClient.isPresent) { + val client = optionalClient.get() + val authToken = client.credentials.authToken + RemoteWipeSuccessRemoteOperation(authToken).execute(client) + } + } + // notify Document Provider + DocumentsStorageProvider.notifyRootsChanged(context) + + // delete app password + val deleteAppPasswordRemoteOperation = DeleteAppPasswordRemoteOperation() + val optionNextcloudClient = createNextcloudClient(user) + + if (optionNextcloudClient.isPresent) { + deleteAppPasswordRemoteOperation.execute(optionNextcloudClient.get()) + } + + // delete cached OwncloudClient + OwnCloudClientManagerFactory.getDefaultSingleton().removeClientFor(user.toOwnCloudAccount()) + + if (userRemoved) { + eventBus.post(AccountRemovedEvent()) + } + + return Result.success() + } + + private fun unregisterPushNotifications( + context: Context, + user: User, + arbitraryDataProvider: ArbitraryDataProvider + ) { + val arbitraryDataPushString = arbitraryDataProvider.getValue(user, PushUtils.KEY_PUSH) + val pushServerUrl = context.resources.getString(R.string.push_server_url) + if (!TextUtils.isEmpty(arbitraryDataPushString) && !TextUtils.isEmpty(pushServerUrl)) { + val gson = Gson() + val pushArbitraryData = gson.fromJson( + arbitraryDataPushString, + PushConfigurationState::class.java + ) + pushArbitraryData.isShouldBeDeleted = true + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + PushUtils.KEY_PUSH, + gson.toJson(pushArbitraryData) + ) + PushUtils.pushRegistrationToServer(userAccountManager, pushArbitraryData.getPushToken()) + } + } + + private fun removeSyncedFolders(context: Context, user: User, clock: Clock) { + val syncedFolders = syncedFolderProvider.syncedFolders + val syncedFolderIds: MutableList = ArrayList() + for (syncedFolder in syncedFolders) { + if (syncedFolder.account == user.accountName) { + syncedFolderIds.add(syncedFolder.id) + } + } + syncedFolderProvider.deleteSyncFoldersForAccount(user) + val filesystemDataProvider = FilesystemDataProvider(context.contentResolver) + for (syncedFolderId in syncedFolderIds) { + filesystemDataProvider.deleteAllEntriesForSyncedFolder(syncedFolderId.toString()) + } + } + + private fun createClient(user: User): Optional { + @Suppress("TooGenericExceptionCaught") // needs migration to newer api to get rid of exceptions + return try { + val context = MainApp.getAppContext() + val factory = OwnCloudClientManagerFactory.getDefaultSingleton() + val client = factory.getClientFor(user.toOwnCloudAccount(), context) + Optional.of(client) + } catch (e: Exception) { + Log_OC.e(this, "Could not create client", e) + Optional.empty() + } + } + + private fun createNextcloudClient(user: User): Optional { + @Suppress("TooGenericExceptionCaught") // needs migration to newer api to get rid of exceptions + return try { + val context = MainApp.getAppContext() + val factory = OwnCloudClientManagerFactory.getDefaultSingleton() + val client = factory.getNextcloudClientFor(user.toOwnCloudAccount(), context) + Optional.of(client) + } catch (e: Exception) { + Log_OC.e(this, "Could not create client", e) + Optional.empty() + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt new file mode 100644 index 000000000000..b9145d7e8769 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -0,0 +1,314 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.content.ContentResolver +import android.content.Context +import android.content.res.Resources +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.core.Clock +import com.nextcloud.client.database.NextcloudDatabase +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.documentscan.GeneratePDFUseCase +import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork +import com.nextcloud.client.integrations.deck.DeckApi +import com.nextcloud.client.jobs.autoUpload.AutoUploadHelper +import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker +import com.nextcloud.client.jobs.autoUpload.FileSystemRepository +import com.nextcloud.client.jobs.download.FileDownloadWorker +import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker +import com.nextcloud.client.jobs.metadata.MetadataWorker +import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.logger.Logger +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.operations.factory.UploadFileOperationFactory +import com.owncloud.android.utils.theme.ViewThemeUtils +import org.greenrobot.eventbus.EventBus +import javax.inject.Inject +import javax.inject.Provider + +/** + * This factory is responsible for creating all background jobs and for injecting worker dependencies. + * + * This class is doing too many things and should be split up into smaller factories. + */ +@Suppress("LongParameterList", "TooManyFunctions") // satisfied by DI +class BackgroundJobFactory @Inject constructor( + private val logger: Logger, + private val preferences: AppPreferences, + private val contentResolver: ContentResolver, + private val clock: Clock, + private val powerManagementService: PowerManagementService, + private val backgroundJobManager: Provider, + private val accountManager: UserAccountManager, + private val resources: Resources, + private val arbitraryDataProvider: ArbitraryDataProvider, + private val uploadsStorageManager: UploadsStorageManager, + private val connectivityService: ConnectivityService, + private val notificationManager: NotificationManager, + private val eventBus: EventBus, + private val deckApi: DeckApi, + private val viewThemeUtils: Provider, + private val localBroadcastManager: Provider, + private val generatePdfUseCase: GeneratePDFUseCase, + private val syncedFolderProvider: SyncedFolderProvider, + private val database: NextcloudDatabase, + private val uploadFileOperationFactory: UploadFileOperationFactory +) : WorkerFactory() { + + @SuppressLint("NewApi") + @Suppress("ComplexMethod") // it's just a trivial dispatch + override fun createWorker( + context: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + val workerClass = try { + Class.forName(workerClassName).kotlin + } catch (ex: ClassNotFoundException) { + null + } + + return if (workerClass == ContentObserverWork::class) { + createContentObserverJob(context, workerParameters) + } else { + when (workerClass) { + ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters) + ContactsImportWork::class -> createContactsImportWork(context, workerParameters) + AutoUploadWorker::class -> createAutoUploadWorker(context, workerParameters) + OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters) + MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters) + NotificationWork::class -> createNotificationWork(context, workerParameters) + AccountRemovalWork::class -> createAccountRemovalWork(context, workerParameters) + CalendarBackupWork::class -> createCalendarBackupWork(context, workerParameters) + CalendarImportWork::class -> createCalendarImportWork(context, workerParameters) + FilesExportWork::class -> createFilesExportWork(context, workerParameters) + FileUploadWorker::class -> createFilesUploadWorker(context, workerParameters) + FileDownloadWorker::class -> createFilesDownloadWorker(context, workerParameters) + GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters) + HealthStatusWork::class -> createHealthStatusWork(context, workerParameters) + TestJob::class -> createTestJob(context, workerParameters) + OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters) + InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters) + MetadataWorker::class -> createMetadataWorker(context, workerParameters) + FolderDownloadWorker::class -> createFolderDownloadWorker(context, workerParameters) + else -> null // caller falls back to default factory + } + } + } + + private fun createOfflineOperationsWorker(context: Context, params: WorkerParameters): ListenableWorker = + OfflineOperationsWorker( + accountManager.user, + context, + connectivityService, + viewThemeUtils.get(), + params + ) + + private fun createFilesExportWork(context: Context, params: WorkerParameters): ListenableWorker = FilesExportWork( + context, + accountManager.user, + contentResolver, + viewThemeUtils.get(), + params + ) + + private fun createContentObserverJob(context: Context, workerParameters: WorkerParameters): ListenableWorker = + ContentObserverWork( + context, + workerParameters, + SyncedFolderProvider(contentResolver, preferences, clock), + powerManagementService, + backgroundJobManager.get(), + AutoUploadHelper( + FileSystemRepository(dao = database.fileSystemDao(), uploadsStorageManager, context) + ) + ) + + private fun createContactsBackupWork(context: Context, params: WorkerParameters): ContactsBackupWork = + ContactsBackupWork( + context, + params, + resources, + arbitraryDataProvider, + contentResolver, + accountManager + ) + + private fun createContactsImportWork(context: Context, params: WorkerParameters): ContactsImportWork = + ContactsImportWork( + context, + params, + logger, + contentResolver + ) + + private fun createCalendarBackupWork(context: Context, params: WorkerParameters): CalendarBackupWork = + CalendarBackupWork( + context, + params, + contentResolver, + accountManager, + preferences + ) + + private fun createCalendarImportWork(context: Context, params: WorkerParameters): CalendarImportWork = + CalendarImportWork( + context, + params, + logger, + contentResolver + ) + + private fun createAutoUploadWorker(context: Context, params: WorkerParameters): AutoUploadWorker = AutoUploadWorker( + context = context, + params = params, + userAccountManager = accountManager, + uploadsStorageManager = uploadsStorageManager, + connectivityService = connectivityService, + powerManagementService = powerManagementService, + syncedFolderProvider = syncedFolderProvider, + repository = FileSystemRepository(dao = database.fileSystemDao(), uploadsStorageManager, context), + viewThemeUtils = viewThemeUtils.get(), + localBroadcastManager = localBroadcastManager.get(), + autoUploadHelper = AutoUploadHelper( + FileSystemRepository(dao = database.fileSystemDao(), uploadsStorageManager, context) + ) + ) + + private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork = OfflineSyncWork( + context = context, + params = params, + contentResolver = contentResolver, + userAccountManager = accountManager, + connectivityService = connectivityService, + powerManagementService = powerManagementService + ) + + private fun createMediaFoldersDetectionWork(context: Context, params: WorkerParameters): MediaFoldersDetectionWork = + MediaFoldersDetectionWork( + context, + params, + resources, + contentResolver, + accountManager, + preferences, + clock, + viewThemeUtils.get(), + syncedFolderProvider + ) + + private fun createNotificationWork(context: Context, params: WorkerParameters): NotificationWork = NotificationWork( + context, + params, + notificationManager, + accountManager, + deckApi, + viewThemeUtils.get() + ) + + private fun createAccountRemovalWork(context: Context, params: WorkerParameters): AccountRemovalWork = + AccountRemovalWork( + context, + params, + uploadsStorageManager, + accountManager, + backgroundJobManager.get(), + clock, + eventBus, + preferences, + syncedFolderProvider + ) + + private fun createFilesUploadWorker(context: Context, params: WorkerParameters): FileUploadWorker = + FileUploadWorker( + uploadsStorageManager, + connectivityService, + powerManagementService, + accountManager, + viewThemeUtils.get(), + localBroadcastManager.get(), + backgroundJobManager.get(), + preferences, + FileSystemRepository(dao = database.fileSystemDao(), uploadsStorageManager, context), + syncedFolderProvider, + context, + uploadFileOperationFactory, + params + ) + + private fun createFilesDownloadWorker(context: Context, params: WorkerParameters): FileDownloadWorker = + FileDownloadWorker( + viewThemeUtils.get(), + accountManager, + localBroadcastManager.get(), + context, + params + ) + + private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork = + GeneratePdfFromImagesWork( + appContext = context, + generatePdfUseCase = generatePdfUseCase, + viewThemeUtils = viewThemeUtils.get(), + notificationManager = notificationManager, + userAccountManager = accountManager, + logger = logger, + params = params + ) + + private fun createHealthStatusWork(context: Context, params: WorkerParameters): HealthStatusWork = HealthStatusWork( + context, + params, + accountManager, + arbitraryDataProvider, + backgroundJobManager.get() + ) + + private fun createTestJob(context: Context, params: WorkerParameters): TestJob = TestJob( + context, + params, + backgroundJobManager.get() + ) + + private fun createInternalTwoWaySyncWork(context: Context, params: WorkerParameters): InternalTwoWaySyncWork = + InternalTwoWaySyncWork( + context, + params, + accountManager, + powerManagementService, + connectivityService, + preferences + ) + + private fun createMetadataWorker(context: Context, params: WorkerParameters): MetadataWorker = MetadataWorker( + context, + params, + accountManager.user + ) + + private fun createFolderDownloadWorker(context: Context, params: WorkerParameters): FolderDownloadWorker = + FolderDownloadWorker( + accountManager, + context, + viewThemeUtils.get(), + localBroadcastManager.get(), + params + ) +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt new file mode 100644 index 000000000000..a859e5808d56 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -0,0 +1,174 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import androidx.lifecycle.LiveData +import androidx.work.ListenableWorker +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.operations.DownloadType + +/** + * This interface allows to control, schedule and monitor all application + * long-running background tasks, such as periodic checks or synchronization. + */ +@Suppress("TooManyFunctions") // we expect this implementation to have rich API +interface BackgroundJobManager { + + /** + * Information about all application background jobs. + */ + val jobs: LiveData> + + fun logStartOfWorker(workerName: String?) + + fun logEndOfWorker(workerName: String?, result: ListenableWorker.Result) + + /** + * Start content observer job that monitors changes in media folders + * and launches synchronization when needed. + * + * This call is idempotent - there will be only one scheduled job + * regardless of number of calls. + */ + fun scheduleContentObserverJob() + + /** + * Schedule periodic contacts backups job. Operating system will + * decide when to start the job. + * + * This call is idempotent - there can be only one scheduled job + * at any given time. + * + * @param user User for which job will be scheduled. + */ + fun schedulePeriodicContactsBackup(user: User) + + /** + * Cancel periodic contacts backup. Existing tasks might finish, but no new + * invocations will occur. + */ + fun cancelPeriodicContactsBackup(user: User) + + /** + * Immediately start single contacts backup job. + * This job will launch independently from periodic contacts backup. + * + * @return Job info with current status; status is null if job does not exist + */ + fun startImmediateContactsBackup(user: User): LiveData + + /** + * Schedule periodic calendar backups job. Operating system will + * decide when to start the job. + * + * This call is idempotent - there can be only one scheduled job + * at any given time. + * + * @param user User for which job will be scheduled. + */ + fun schedulePeriodicCalendarBackup(user: User) + + /** + * Cancel periodic calendar backup. Existing tasks might finish, but no new + * invocations will occur. + */ + fun cancelPeriodicCalendarBackup(user: User) + + /** + * Immediately start single calendar backup job. + * This job will launch independently from periodic calendar backup. + * + * @return Job info with current status; status is null if job does not exist + */ + fun startImmediateCalendarBackup(user: User): LiveData + + /** + * Immediately start contacts import job. Import job will be started only once. + * If new job is started while existing job is running - request will be ignored + * and currently running job will continue running. + * + * @param contactsAccountName Target contacts account name; null for local contacts + * @param contactsAccountType Target contacts account type; null for local contacts + * @param vCardFilePath Path to file containing all contact entries + * @param selectedContactsFilePath File path of list of contact indices to import from [vCardFilePath] file + * + * @return Job info with current status; status is null if job does not exist + */ + fun startImmediateContactsImport( + contactsAccountName: String?, + contactsAccountType: String?, + vCardFilePath: String, + selectedContactsFilePath: String + ): LiveData + + /** + * Immediately start calendar import job. Import job will be started only once. + * If new job is started while existing job is running - request will be ignored + * and currently running job will continue running. + * + * @param calendarPaths Array of paths of calendar files to import from + * + * @return Job info with current status; status is null if job does not exist + */ + fun startImmediateCalendarImport(calendarPaths: Map): LiveData + + fun startImmediateFilesExportJob(files: Collection): LiveData + + fun startAutoUpload(syncedFolder: SyncedFolder, overridePowerSaving: Boolean = false) + + fun cancelTwoWaySyncJob() + + fun scheduleOfflineSync() + + fun scheduleMediaFoldersDetectionJob() + fun startMediaFoldersDetectionJob() + + fun startNotificationJob(subject: String, signature: String) + fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean) + fun startFilesUploadJob( + user: User, + uploadIds: LongArray, + showSameFileAlreadyExistsNotification: Boolean, + skipAutoUploadCheck: Boolean = false + ) + fun getFileUploads(user: User): LiveData> + fun cancelFilesUploadJob(user: User) + fun isStartFileUploadJobScheduled(accountName: String): Boolean + + fun cancelFilesDownloadJob(accountName: String, fileId: Long) + + @Suppress("LongParameterList") + fun startFileDownloadJob( + user: User, + file: OCFile, + behaviour: String, + downloadType: DownloadType?, + activityName: String, + packageName: String, + conflictUploadId: Long? + ) + + fun startPdfGenerateAndUploadWork(user: User, uploadFolder: String, imagePaths: List, pdfPath: String) + + fun scheduleTestJob() + fun startImmediateTestJob() + fun cancelTestJob() + + fun pruneJobs() + fun cancelAllJobs() + fun schedulePeriodicHealthStatus() + fun startHealthStatus() + fun startOfflineOperations() + fun startPeriodicallyOfflineOperation() + fun scheduleInternal2WaySync(intervalMinutes: Long) + fun cancelAllFilesDownloadJobs() + fun startMetadataSyncJob(currentDirPath: String) + fun downloadFolder(folder: OCFile, accountName: String) + fun cancelFolderDownload() +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt new file mode 100644 index 000000000000..4a28b1715dea --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -0,0 +1,827 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.provider.MediaStore +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.Operation +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.nextcloud.client.account.User +import com.nextcloud.client.core.Clock +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork +import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker +import com.nextcloud.client.jobs.download.FileDownloadWorker +import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker +import com.nextcloud.client.jobs.metadata.MetadataWorker +import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.jobs.worker.WorkerFilesPayload +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.extensions.isWorkScheduled +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.Duration +import java.util.Date +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.reflect.KClass + +/** + * Note to maintainers + * + * Since [androidx.work.WorkManager] is missing API to easily attach worker metadata, + * we use tags API to attach our custom metadata. + * + * To create new job request, use [BackgroundJobManagerImpl.oneTimeRequestBuilder] and + * [BackgroundJobManagerImpl.periodicRequestBuilder] calls, instead of calling + * platform builders. Those methods will create builders pre-set with mandatory tags. + * + * Since Google is notoriously releasing new background job services, [androidx.work.WorkManager] API is + * considered private implementation detail and should not be leaked through the interface, to minimize + * potential migration cost in the future. + */ +@Suppress("TooManyFunctions") // we expect this implementation to have rich API +internal class BackgroundJobManagerImpl( + private val workManager: WorkManager, + private val clock: Clock, + private val preferences: AppPreferences +) : BackgroundJobManager, + Injectable { + + companion object { + private const val TAG = "BackgroundJobManagerImpl" + + const val TAG_ALL = "*" // This tag allows us to retrieve list of all jobs run by Nextcloud client + const val JOB_CONTENT_OBSERVER = "content_observer" + const val JOB_PERIODIC_CONTACTS_BACKUP = "periodic_contacts_backup" + const val JOB_IMMEDIATE_CONTACTS_BACKUP = "immediate_contacts_backup" + const val JOB_IMMEDIATE_CONTACTS_IMPORT = "immediate_contacts_import" + const val JOB_PERIODIC_CALENDAR_BACKUP = "periodic_calendar_backup" + const val JOB_IMMEDIATE_CALENDAR_IMPORT = "immediate_calendar_import" + const val JOB_PERIODIC_FILES_SYNC = "periodic_files_sync" + const val JOB_IMMEDIATE_FILES_SYNC = "immediate_files_sync" + const val JOB_PERIODIC_OFFLINE_SYNC = "periodic_offline_sync" + const val JOB_PERIODIC_MEDIA_FOLDER_DETECTION = "periodic_media_folder_detection" + const val JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION = "immediate_media_folder_detection" + const val JOB_NOTIFICATION = "notification" + const val JOB_ACCOUNT_REMOVAL = "account_removal" + const val JOB_FILES_UPLOAD = "files_upload" + const val JOB_FOLDER_DOWNLOAD = "folder_download" + const val JOB_FILES_DOWNLOAD = "files_download" + const val JOB_PDF_GENERATION = "pdf_generation" + const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup" + const val JOB_IMMEDIATE_FILES_EXPORT = "immediate_files_export" + const val JOB_OFFLINE_OPERATIONS = "offline_operations" + const val JOB_PERIODIC_OFFLINE_OPERATIONS = "periodic_offline_operations" + const val JOB_PERIODIC_HEALTH_STATUS = "periodic_health_status" + const val JOB_IMMEDIATE_HEALTH_STATUS = "immediate_health_status" + const val JOB_DOWNLOAD_FOLDER = "download_folder" + const val JOB_METADATA_SYNC = "metadata_sync" + const val JOB_INTERNAL_TWO_WAY_SYNC = "internal_two_way_sync" + + const val JOB_TEST = "test_job" + + const val TAG_PREFIX_NAME = "name" + const val TAG_PREFIX_USER = "user" + const val TAG_PREFIX_CLASS = "class" + const val TAG_PREFIX_START_TIMESTAMP = "timestamp" + val PREFIXES = setOf(TAG_PREFIX_NAME, TAG_PREFIX_USER, TAG_PREFIX_START_TIMESTAMP, TAG_PREFIX_CLASS) + const val NOT_SET_VALUE = "not set" + const val PERIODIC_BACKUP_INTERVAL_MINUTES = 24 * 60L + const val DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES = 15L + const val OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES = 5L + const val DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L + const val DEFAULT_BACKOFF_CRITERIA_DELAY_SEC = 300L + + private const val KEEP_LOG_MILLIS = 1000 * 60 * 60 * 24 * 3L + + fun formatNameTag(name: String, user: User? = null): String = if (user == null) { + "$TAG_PREFIX_NAME:$name" + } else { + "$TAG_PREFIX_NAME:$name ${user.accountName}" + } + + fun formatUserTag(user: User): String = "$TAG_PREFIX_USER:${user.accountName}" + fun formatClassTag(jobClass: KClass): String = "$TAG_PREFIX_CLASS:${jobClass.simpleName}" + fun formatTimeTag(startTimestamp: Long): String = "$TAG_PREFIX_START_TIMESTAMP:$startTimestamp" + + fun parseTag(tag: String): Pair? { + val key = tag.substringBefore(":", "") + val value = tag.substringAfter(":", "") + return if (key in PREFIXES) { + key to value + } else { + null + } + } + + fun parseTimestamp(timestamp: String): Date = try { + val ms = timestamp.toLong() + Date(ms) + } catch (ex: NumberFormatException) { + Date(0) + } + + /** + * Convert platform [androidx.work.WorkInfo] object into application-specific [JobInfo] model. + * Conversion extracts work metadata from tags. + */ + fun fromWorkInfo(info: WorkInfo?): JobInfo? = if (info != null) { + val metadata = mutableMapOf() + info.tags.forEach { parseTag(it)?.let { metadata[it.first] = it.second } } + val timestamp = parseTimestamp(metadata.get(TAG_PREFIX_START_TIMESTAMP) ?: "0") + JobInfo( + id = info.id, + state = info.state.toString(), + name = metadata.get(TAG_PREFIX_NAME) ?: NOT_SET_VALUE, + user = metadata.get(TAG_PREFIX_USER) ?: NOT_SET_VALUE, + started = timestamp, + progress = info.progress.getInt("progress", -1), + workerClass = metadata.get(TAG_PREFIX_CLASS) ?: NOT_SET_VALUE + ) + } else { + null + } + + fun deleteOldLogs(logEntries: MutableList): MutableList { + logEntries.removeIf { + return@removeIf ( + it.started != null && + Date(Date().time - KEEP_LOG_MILLIS).after(it.started) + ) || + ( + it.finished != null && + Date(Date().time - KEEP_LOG_MILLIS).after(it.finished) + ) + } + return logEntries + } + } + + private val defaultDispatcherScope = CoroutineScope(Dispatchers.Default) + + override fun logStartOfWorker(workerName: String?) { + val logs = deleteOldLogs(preferences.readLogEntry().toMutableList()) + + if (workerName == null) { + logs.add(LogEntry(Date(), null, null, NOT_SET_VALUE)) + } else { + logs.add(LogEntry(Date(), null, null, workerName)) + } + preferences.saveLogEntry(logs) + } + + override fun logEndOfWorker(workerName: String?, result: ListenableWorker.Result) { + val logs = deleteOldLogs(preferences.readLogEntry().toMutableList()) + if (workerName == null) { + logs.add(LogEntry(null, Date(), result.toString(), NOT_SET_VALUE)) + } else { + logs.add(LogEntry(null, Date(), result.toString(), workerName)) + } + preferences.saveLogEntry(logs) + } + + /** + * Create [OneTimeWorkRequest.Builder] pre-set with common attributes + */ + private fun oneTimeRequestBuilder( + jobClass: KClass, + jobName: String, + user: User? = null, + constraints: Constraints = Constraints.Builder().build() + ): OneTimeWorkRequest.Builder { + val builder = OneTimeWorkRequest.Builder(jobClass.java) + .addTag(TAG_ALL) + .addTag(formatNameTag(jobName, user)) + .addTag(formatTimeTag(clock.currentTime)) + .addTag(formatClassTag(jobClass)) + .setConstraints(constraints) + user?.let { builder.addTag(formatUserTag(it)) } + return builder + } + + /** + * Create [PeriodicWorkRequest] pre-set with common attributes + */ + private fun periodicRequestBuilder( + jobClass: KClass, + jobName: String, + intervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES, + flexIntervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES, + user: User? = null, + constraints: Constraints = Constraints.Builder().build() + ): PeriodicWorkRequest.Builder { + val builder = PeriodicWorkRequest.Builder( + jobClass.java, + intervalMins, + TimeUnit.MINUTES, + flexIntervalMins, + TimeUnit.MINUTES + ) + .addTag(TAG_ALL) + .addTag(formatNameTag(jobName, user)) + .addTag(formatTimeTag(clock.currentTime)) + .addTag(formatClassTag(jobClass)) + .setConstraints(constraints) + user?.let { builder.addTag(formatUserTag(it)) } + return builder + } + + private fun WorkManager.getJobInfo(id: UUID): LiveData { + val workInfo = getWorkInfoByIdLiveData(id) + return workInfo.map { fromWorkInfo(it) } + } + + /** + * Cancel work using name tag with optional user scope. + * All work instances will be cancelled. + */ + private fun WorkManager.cancelJob(name: String, user: User? = null): Operation { + val tag = formatNameTag(name, user) + return cancelAllWorkByTag(tag) + } + + override val jobs: LiveData> + get() { + val workInfo = workManager.getWorkInfosByTagLiveData("*") + return workInfo.map { it -> it.map { fromWorkInfo(it) ?: JobInfo() }.sortedBy { it.started }.reversed() } + } + + override fun schedulePeriodicContactsBackup(user: User) { + val data = Data.Builder() + .putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName) + .putBoolean(ContactsBackupWork.KEY_FORCE, true) + .build() + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = periodicRequestBuilder( + jobClass = ContactsBackupWork::class, + jobName = JOB_PERIODIC_CONTACTS_BACKUP, + intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES, + user = user + ) + .setInputData(data) + .setConstraints(constraints) + .build() + + workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_CONTACTS_BACKUP, ExistingPeriodicWorkPolicy.KEEP, request) + } + + override fun cancelPeriodicContactsBackup(user: User) { + workManager.cancelJob(JOB_PERIODIC_CONTACTS_BACKUP, user) + } + + override fun startImmediateContactsImport( + contactsAccountName: String?, + contactsAccountType: String?, + vCardFilePath: String, + selectedContactsFilePath: String + ): LiveData { + val data = Data.Builder() + .putString(ContactsImportWork.ACCOUNT_NAME, contactsAccountName) + .putString(ContactsImportWork.ACCOUNT_TYPE, contactsAccountType) + .putString(ContactsImportWork.VCARD_FILE_PATH, vCardFilePath) + .putString(ContactsImportWork.SELECTED_CONTACTS_FILE_PATH, selectedContactsFilePath) + .build() + + val constraints = Constraints.Builder() + .setRequiresCharging(false) + .build() + + val request = oneTimeRequestBuilder(ContactsImportWork::class, JOB_IMMEDIATE_CONTACTS_IMPORT) + .setInputData(data) + .setConstraints(constraints) + .build() + + workManager.enqueueUniqueWork(JOB_IMMEDIATE_CONTACTS_IMPORT, ExistingWorkPolicy.KEEP, request) + + return workManager.getJobInfo(request.id) + } + + override fun startImmediateCalendarImport(calendarPaths: Map): LiveData { + val data = Data.Builder() + .putAll(calendarPaths) + .build() + + val constraints = Constraints.Builder() + .setRequiresCharging(false) + .build() + + val request = oneTimeRequestBuilder(CalendarImportWork::class, JOB_IMMEDIATE_CALENDAR_IMPORT) + .setInputData(data) + .setConstraints(constraints) + .build() + + workManager.enqueueUniqueWork(JOB_IMMEDIATE_CALENDAR_IMPORT, ExistingWorkPolicy.KEEP, request) + + return workManager.getJobInfo(request.id) + } + + override fun startImmediateFilesExportJob(files: Collection): LiveData { + val path = WorkerFilesPayload.write(files.toList()) ?: run { + Log_OC.w(TAG, "File export was started without any file") + return MutableLiveData(null) + } + + val data = Data.Builder() + .putString(FilesExportWork.FILES_TO_DOWNLOAD, path) + .build() + + val request = oneTimeRequestBuilder(FilesExportWork::class, JOB_IMMEDIATE_FILES_EXPORT) + .setInputData(data) + .build() + + workManager.enqueueUniqueWork(JOB_IMMEDIATE_FILES_EXPORT, ExistingWorkPolicy.APPEND_OR_REPLACE, request) + + return workManager.getJobInfo(request.id) + } + + override fun startImmediateContactsBackup(user: User): LiveData { + val data = Data.Builder() + .putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName) + .putBoolean(ContactsBackupWork.KEY_FORCE, true) + .build() + + val request = oneTimeRequestBuilder(ContactsBackupWork::class, JOB_IMMEDIATE_CONTACTS_BACKUP, user) + .setInputData(data) + .build() + + workManager.enqueueUniqueWork(JOB_IMMEDIATE_CONTACTS_BACKUP, ExistingWorkPolicy.KEEP, request) + return workManager.getJobInfo(request.id) + } + + override fun startImmediateCalendarBackup(user: User): LiveData { + val data = Data.Builder() + .putString(CalendarBackupWork.ACCOUNT, user.accountName) + .putBoolean(CalendarBackupWork.FORCE, true) + .build() + + val request = oneTimeRequestBuilder(CalendarBackupWork::class, JOB_IMMEDIATE_CALENDAR_BACKUP, user) + .setInputData(data) + .build() + + workManager.enqueueUniqueWork(JOB_IMMEDIATE_CALENDAR_BACKUP, ExistingWorkPolicy.KEEP, request) + return workManager.getJobInfo(request.id) + } + + override fun schedulePeriodicCalendarBackup(user: User) { + val data = Data.Builder() + .putString(CalendarBackupWork.ACCOUNT, user.accountName) + .putBoolean(CalendarBackupWork.FORCE, true) + .build() + val request = periodicRequestBuilder( + jobClass = CalendarBackupWork::class, + jobName = JOB_PERIODIC_CALENDAR_BACKUP, + intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES, + user = user + ).setInputData(data).build() + + workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_CALENDAR_BACKUP, ExistingPeriodicWorkPolicy.KEEP, request) + } + + override fun cancelPeriodicCalendarBackup(user: User) { + workManager.cancelJob(JOB_PERIODIC_CALENDAR_BACKUP, user) + } + + override fun startPeriodicallyOfflineOperation() { + val inputData = Data.Builder() + .putString(OfflineOperationsWorker.JOB_NAME, JOB_PERIODIC_OFFLINE_OPERATIONS) + .build() + + val request = periodicRequestBuilder( + jobClass = OfflineOperationsWorker::class, + jobName = JOB_PERIODIC_OFFLINE_OPERATIONS, + intervalMins = OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES + ) + .setInputData(inputData) + .build() + + workManager.enqueueUniquePeriodicWork( + JOB_PERIODIC_OFFLINE_OPERATIONS, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } + + override fun startOfflineOperations() { + val inputData = Data.Builder() + .putString(OfflineOperationsWorker.JOB_NAME, JOB_OFFLINE_OPERATIONS) + .build() + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + // Backoff criteria define how the system should retry the task if it fails. + // LINEAR means each retry will be delayed linearly (e.g., 10s, 20s, 30s...) + // DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES is used as the initial delay duration. + val backoffCriteriaPolicy = BackoffPolicy.LINEAR + val backoffCriteriaDelay = DEFAULT_BACKOFF_CRITERIA_DELAY_SEC + + val request = + oneTimeRequestBuilder(OfflineOperationsWorker::class, JOB_OFFLINE_OPERATIONS, constraints = constraints) + .setBackoffCriteria( + backoffCriteriaPolicy, + backoffCriteriaDelay, + TimeUnit.SECONDS + ) + .setInputData(inputData) + .build() + + workManager.enqueueUniqueWork( + JOB_OFFLINE_OPERATIONS, + ExistingWorkPolicy.KEEP, + request + ) + } + + @Suppress("MagicNumber") + override fun scheduleContentObserverJob() { + val constrains = Constraints.Builder() + .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Files.getContentUri("external"), true) + .setTriggerContentUpdateDelay(Duration.ofSeconds(5)) + .setTriggerContentMaxDelay(Duration.ofSeconds(10)) + .build() + + val request = oneTimeRequestBuilder(ContentObserverWork::class, JOB_CONTENT_OBSERVER) + .setConstraints(constrains) + .build() + + workManager.enqueueUniqueWork(JOB_CONTENT_OBSERVER, ExistingWorkPolicy.REPLACE, request) + } + + override fun startAutoUpload(syncedFolder: SyncedFolder, overridePowerSaving: Boolean) { + val syncedFolderID = syncedFolder.id + + val arguments = Data.Builder() + .putBoolean(AutoUploadWorker.OVERRIDE_POWER_SAVING, overridePowerSaving) + .putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID) + .build() + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresCharging(syncedFolder.isChargingOnly) + .build() + + val request = oneTimeRequestBuilder( + jobClass = AutoUploadWorker::class, + jobName = JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID + ) + .setInputData(arguments) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + DEFAULT_BACKOFF_CRITERIA_DELAY_SEC, + TimeUnit.SECONDS + ) + .build() + + workManager.enqueueUniqueWork( + JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID, + ExistingWorkPolicy.KEEP, + request + ) + } + + override fun cancelTwoWaySyncJob() { + workManager.cancelJob(JOB_INTERNAL_TWO_WAY_SYNC) + } + + override fun cancelAllFilesDownloadJobs() { + workManager.cancelAllWorkByTag(formatClassTag(FileDownloadWorker::class)) + } + + override fun startMetadataSyncJob(currentDirPath: String) { + val inputData = Data.Builder() + .putString(MetadataWorker.FILE_PATH, currentDirPath) + .build() + + val constrains = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + + val request = oneTimeRequestBuilder(MetadataWorker::class, JOB_METADATA_SYNC) + .setConstraints(constrains) + .setInputData(inputData) + .build() + + workManager.enqueueUniqueWork( + JOB_METADATA_SYNC, + ExistingWorkPolicy.REPLACE, + request + ) + } + + override fun scheduleOfflineSync() { + val constrains = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .build() + + val request = periodicRequestBuilder(OfflineSyncWork::class, JOB_PERIODIC_OFFLINE_SYNC) + .setConstraints(constrains) + .build() + + workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_OFFLINE_SYNC, ExistingPeriodicWorkPolicy.KEEP, request) + } + + override fun scheduleMediaFoldersDetectionJob() { + val request = periodicRequestBuilder(MediaFoldersDetectionWork::class, JOB_PERIODIC_MEDIA_FOLDER_DETECTION) + .build() + + workManager.enqueueUniquePeriodicWork( + JOB_PERIODIC_MEDIA_FOLDER_DETECTION, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + override fun startMediaFoldersDetectionJob() { + val request = oneTimeRequestBuilder(MediaFoldersDetectionWork::class, JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION) + .build() + + workManager.enqueueUniqueWork( + JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION, + ExistingWorkPolicy.KEEP, + request + ) + } + + override fun startNotificationJob(subject: String, signature: String) { + val data = Data.Builder() + .putString(NotificationWork.KEY_NOTIFICATION_SUBJECT, subject) + .putString(NotificationWork.KEY_NOTIFICATION_SIGNATURE, signature) + .build() + + val request = oneTimeRequestBuilder(NotificationWork::class, JOB_NOTIFICATION) + .setInputData(data) + .build() + + workManager.enqueue(request) + } + + override fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean) { + val data = Data.Builder() + .putString(AccountRemovalWork.ACCOUNT, accountName) + .putBoolean(AccountRemovalWork.REMOTE_WIPE, remoteWipe) + .build() + + val request = oneTimeRequestBuilder(AccountRemovalWork::class, JOB_ACCOUNT_REMOVAL) + .setInputData(data) + .build() + + workManager.enqueue(request) + } + + private fun startFileUploadJobTag(accountName: String): String = JOB_FILES_UPLOAD + accountName + + override fun isStartFileUploadJobScheduled(accountName: String): Boolean = + workManager.isWorkScheduled(startFileUploadJobTag(accountName)) + + /** + * This method supports initiating uploads for various scenarios, including: + * - New upload batches + * - Failed uploads + * - FilesSyncWork + * - ... + * + * @param user The user for whom the upload job is being created. + * @param uploadIds Array of upload IDs to be processed. These IDs originate from multiple sources + * and cannot be determined directly from the account name or a single function + * within the worker. + */ + override fun startFilesUploadJob( + user: User, + uploadIds: LongArray, + showSameFileAlreadyExistsNotification: Boolean, + skipAutoUploadCheck: Boolean + ) { + defaultDispatcherScope.launch { + val batchSize = FileUploadHelper.MAX_FILE_COUNT + val batches = uploadIds.toList().chunked(batchSize) + val tag = startFileUploadJobTag(user.accountName) + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val dataBuilder = Data.Builder() + .putBoolean( + FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, + showSameFileAlreadyExistsNotification + ) + .putBoolean(FileUploadWorker.SKIP_AUTO_UPLOAD_CHECK, skipAutoUploadCheck) + .putString(FileUploadWorker.ACCOUNT, user.accountName) + .putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, uploadIds.size) + + val workRequests = batches.mapIndexed { index, batch -> + dataBuilder + .putLongArray(FileUploadWorker.UPLOAD_IDS, batch.toLongArray()) + .putInt(FileUploadWorker.CURRENT_BATCH_INDEX, index) + + oneTimeRequestBuilder(FileUploadWorker::class, JOB_FILES_UPLOAD, user) + .addTag(tag) + .setInputData(dataBuilder.build()) + .setConstraints(constraints) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + } + + // Chain the work requests sequentially + if (workRequests.isNotEmpty()) { + var workChain = workManager.beginUniqueWork( + tag, + ExistingWorkPolicy.APPEND_OR_REPLACE, + workRequests.first() + ) + + workRequests.drop(1).forEach { request -> + workChain = workChain.then(request) + } + + workChain.enqueue() + } + } + } + + private fun startFileDownloadJobTag(accountName: String, fileId: Long): String = + JOB_FOLDER_DOWNLOAD + accountName + fileId + + override fun startFileDownloadJob( + user: User, + file: OCFile, + behaviour: String, + downloadType: DownloadType?, + activityName: String, + packageName: String, + conflictUploadId: Long? + ) { + val tag = startFileDownloadJobTag(user.accountName, file.fileId) + + val data = workDataOf( + FileDownloadWorker.ACCOUNT_NAME to user.accountName, + FileDownloadWorker.FILE_REMOTE_PATH to file.remotePath, + FileDownloadWorker.BEHAVIOUR to behaviour, + FileDownloadWorker.DOWNLOAD_TYPE to downloadType.toString(), + FileDownloadWorker.ACTIVITY_NAME to activityName, + FileDownloadWorker.PACKAGE_NAME to packageName, + FileDownloadWorker.CONFLICT_UPLOAD_ID to conflictUploadId + ) + + val request = oneTimeRequestBuilder(FileDownloadWorker::class, JOB_FILES_DOWNLOAD, user) + .addTag(tag) + .setInputData(data) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + // Since for each file new FileDownloadWorker going to be scheduled, + // better to use ExistingWorkPolicy.KEEP policy. + workManager.enqueueUniqueWork(tag, ExistingWorkPolicy.KEEP, request) + } + + override fun getFileUploads(user: User): LiveData> { + val workInfo = workManager.getWorkInfosByTagLiveData(formatNameTag(JOB_FILES_UPLOAD, user)) + return workInfo.map { it -> it.map { fromWorkInfo(it) ?: JobInfo() } } + } + + override fun cancelFilesUploadJob(user: User) { + workManager.cancelJob(JOB_FILES_UPLOAD, user) + } + + override fun cancelFilesDownloadJob(accountName: String, fileId: Long) { + workManager.cancelAllWorkByTag(startFileDownloadJobTag(accountName, fileId)) + } + + override fun startPdfGenerateAndUploadWork( + user: User, + uploadFolder: String, + imagePaths: List, + pdfPath: String + ) { + val data = workDataOf( + GeneratePdfFromImagesWork.INPUT_IMAGE_FILE_PATHS to imagePaths.toTypedArray(), + GeneratePdfFromImagesWork.INPUT_OUTPUT_FILE_PATH to pdfPath, + GeneratePdfFromImagesWork.INPUT_UPLOAD_ACCOUNT to user.accountName, + GeneratePdfFromImagesWork.INPUT_UPLOAD_FOLDER to uploadFolder + ) + val request = oneTimeRequestBuilder(GeneratePdfFromImagesWork::class, JOB_PDF_GENERATION) + .setInputData(data) + .build() + workManager.enqueue(request) + } + + override fun scheduleTestJob() { + val request = periodicRequestBuilder(TestJob::class, JOB_TEST) + .setInitialDelay(DEFAULT_IMMEDIATE_JOB_DELAY_SEC, TimeUnit.SECONDS) + .build() + workManager.enqueueUniquePeriodicWork(JOB_TEST, ExistingPeriodicWorkPolicy.REPLACE, request) + } + + override fun startImmediateTestJob() { + val request = oneTimeRequestBuilder(TestJob::class, JOB_TEST) + .build() + workManager.enqueueUniqueWork(JOB_TEST, ExistingWorkPolicy.REPLACE, request) + } + + override fun cancelTestJob() { + workManager.cancelAllWorkByTag(formatNameTag(JOB_TEST)) + } + + override fun pruneJobs() { + workManager.pruneWork() + } + + override fun cancelAllJobs() { + workManager.cancelAllWorkByTag(TAG_ALL) + } + + override fun schedulePeriodicHealthStatus() { + val request = periodicRequestBuilder( + jobClass = HealthStatusWork::class, + jobName = JOB_PERIODIC_HEALTH_STATUS, + intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES + ).build() + + workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_HEALTH_STATUS, ExistingPeriodicWorkPolicy.KEEP, request) + } + + override fun startHealthStatus() { + val request = oneTimeRequestBuilder(HealthStatusWork::class, JOB_IMMEDIATE_HEALTH_STATUS) + .build() + + workManager.enqueueUniqueWork( + JOB_IMMEDIATE_HEALTH_STATUS, + ExistingWorkPolicy.KEEP, + request + ) + } + + override fun scheduleInternal2WaySync(intervalMinutes: Long) { + val request = periodicRequestBuilder( + jobClass = InternalTwoWaySyncWork::class, + jobName = JOB_INTERNAL_TWO_WAY_SYNC, + intervalMins = intervalMinutes + ) + .setInitialDelay(intervalMinutes, TimeUnit.MINUTES) + .build() + + workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request) + } + + override fun downloadFolder(folder: OCFile, accountName: String) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresStorageNotLow(true) + .build() + + val data = Data.Builder() + .putLong(FolderDownloadWorker.FOLDER_ID, folder.fileId) + .putString(FolderDownloadWorker.ACCOUNT_NAME, accountName) + .build() + + val request = oneTimeRequestBuilder(FolderDownloadWorker::class, JOB_DOWNLOAD_FOLDER) + .addTag(JOB_DOWNLOAD_FOLDER) + .setInputData(data) + .setConstraints(constraints) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + workManager.enqueueUniqueWork(JOB_DOWNLOAD_FOLDER, ExistingWorkPolicy.APPEND_OR_REPLACE, request) + } + + override fun cancelFolderDownload() { + workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/CalendarBackupWork.kt b/app/src/main/java/com/nextcloud/client/jobs/CalendarBackupWork.kt new file mode 100644 index 000000000000..ffd24857c4ab --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/CalendarBackupWork.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.ContentResolver +import android.content.Context +import android.text.TextUtils +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.lib.common.utils.Log_OC +import third_parties.sufficientlysecure.AndroidCalendar +import third_parties.sufficientlysecure.SaveCalendar +import java.util.Calendar + +class CalendarBackupWork( + appContext: Context, + params: WorkerParameters, + private val contentResolver: ContentResolver, + private val accountManager: UserAccountManager, + private val preferences: AppPreferences +) : Worker(appContext, params) { + + companion object { + val TAG = CalendarBackupWork::class.java.simpleName + const val ACCOUNT = "account" + const val FORCE = "force" + const val JOB_INTERVAL_MS: Long = 24 * 60 * 60 * 1000 + } + + override fun doWork(): Result { + val accountName = inputData.getString(ACCOUNT) ?: "" + val optionalUser = accountManager.getUser(accountName) + if (!optionalUser.isPresent || TextUtils.isEmpty(accountName)) { + // no account provided + Log_OC.d(TAG, "User not present") + return Result.failure() + } + val lastExecution = preferences.calendarLastBackup + + val force = inputData.getBoolean(FORCE, false) + if (force || lastExecution + JOB_INTERVAL_MS < Calendar.getInstance().timeInMillis) { + val calendars = AndroidCalendar.loadAll(contentResolver) + Log_OC.d(TAG, "Saving ${calendars.size} calendars") + calendars.forEach { calendar -> + SaveCalendar( + applicationContext, + calendar, + preferences, + optionalUser.get() + ).start() + } + + // store execution date + preferences.calendarLastBackup = Calendar.getInstance().timeInMillis + } else { + Log_OC.d(TAG, "last execution less than 24h ago") + } + + return Result.success() + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/CalendarImportWork.kt b/app/src/main/java/com/nextcloud/client/jobs/CalendarImportWork.kt new file mode 100644 index 000000000000..03a4d42f71d8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/CalendarImportWork.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.ContentResolver +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.logger.Logger +import com.owncloud.android.lib.common.utils.Log_OC +import net.fortuna.ical4j.data.CalendarBuilder +import third_parties.sufficientlysecure.AndroidCalendar +import third_parties.sufficientlysecure.CalendarSource +import third_parties.sufficientlysecure.ProcessVEvent +import java.io.File + +class CalendarImportWork( + private val appContext: Context, + params: WorkerParameters, + private val logger: Logger, + private val contentResolver: ContentResolver +) : Worker(appContext, params) { + + companion object { + const val TAG = "CalendarImportWork" + } + + @Suppress("TooGenericExceptionCaught") + override fun doWork(): Result { + val calendars = inputData.keyValueMap as? Map<*, *> + if (calendars == null) { + logger.d(TAG, "CalendarImportWork cancelled due to null empty input data") + return Result.failure() + } + + val calendarBuilder = CalendarBuilder() + + for ((path, selectedCalendarIndex) in calendars) { + try { + if (path !is String || selectedCalendarIndex !is Int) { + logger.d(TAG, "Skipping wrong input data types: $path - $selectedCalendarIndex") + continue + } + + logger.d(TAG, "Import calendar from $path") + + val file = File(path) + val calendarSource = CalendarSource( + file.toURI().toURL().toString(), + null, + null, + null, + appContext + ) + + val calendarList = AndroidCalendar.loadAll(contentResolver) + if (selectedCalendarIndex >= calendarList.size) { + logger.d(TAG, "Skipping selectedCalendarIndex out of bound") + continue + } + + val selectedCalendar = calendarList[selectedCalendarIndex] + + ProcessVEvent( + appContext, + calendarBuilder.build(calendarSource.stream), + selectedCalendar, + true + ).run() + } catch (e: Exception) { + Log_OC.e(TAG, "skipping calendarIndex: $selectedCalendarIndex due to: $e") + } + } + + logger.d(TAG, "CalendarImportWork successfully completed") + return Result.success() + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt b/app/src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt new file mode 100644 index 000000000000..4a01346dc993 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt @@ -0,0 +1,261 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.ComponentName +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.res.Resources +import android.database.Cursor +import android.net.Uri +import android.os.IBinder +import android.provider.ContactsContract +import android.text.TextUtils +import android.text.format.DateFormat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.files.UploadRequest +import com.nextcloud.client.jobs.transfer.TransferManagerConnection +import com.nextcloud.client.jobs.upload.PostUploadAction +import com.nextcloud.client.jobs.upload.UploadTrigger +import com.owncloud.android.R +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.services.OperationsService +import com.owncloud.android.services.OperationsService.OperationsServiceBinder +import com.owncloud.android.ui.activity.ContactsPreferenceActivity +import ezvcard.Ezvcard +import ezvcard.VCardVersion +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.util.Calendar + +@Suppress("LongParameterList") // legacy code +class ContactsBackupWork( + appContext: Context, + params: WorkerParameters, + private val resources: Resources, + private val arbitraryDataProvider: ArbitraryDataProvider, + private val contentResolver: ContentResolver, + private val accountManager: UserAccountManager +) : Worker(appContext, params) { + + companion object { + val TAG = ContactsBackupWork::class.java.simpleName + const val KEY_ACCOUNT = "account" + const val KEY_FORCE = "force" + const val JOB_INTERVAL_MS: Long = 24L * 60L * 60L * 1000L + const val BUFFER_SIZE = 1024 + } + + private var operationsServiceConnection: OperationsServiceConnection? = null + private var operationsServiceBinder: OperationsServiceBinder? = null + + @Suppress("ReturnCount") // pre-existing issue + override fun doWork(): Result { + val accountName = inputData.getString(KEY_ACCOUNT) ?: "" + if (TextUtils.isEmpty(accountName)) { + // no account provided + return Result.failure() + } + val optionalUser = accountManager.getUser(accountName) + if (!optionalUser.isPresent) { + return Result.failure() + } + val user = optionalUser.get() + val lastExecution = arbitraryDataProvider.getLongValue( + user, + ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP + ) + val force = inputData.getBoolean(KEY_FORCE, false) + if (force || lastExecution + JOB_INTERVAL_MS < Calendar.getInstance().timeInMillis) { + Log_OC.d(TAG, "start contacts backup job") + val backupFolder: String = resources.getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR + val daysToExpire: Int = applicationContext.getResources().getInteger(R.integer.contacts_backup_expire) + backupContact(user, backupFolder) + // bind to Operations Service + operationsServiceConnection = OperationsServiceConnection( + this, + daysToExpire, + backupFolder, + user + ) + applicationContext.bindService( + Intent(applicationContext, OperationsService::class.java), + operationsServiceConnection as OperationsServiceConnection, + OperationsService.BIND_AUTO_CREATE + ) + // store execution date + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP, + Calendar.getInstance().timeInMillis + ) + } else { + Log_OC.d(TAG, "last execution less than 24h ago") + } + return Result.success() + } + + private fun backupContact(user: User, backupFolder: String) { + val vCard = ArrayList() + val cursor = contentResolver.query( + ContactsContract.Contacts.CONTENT_URI, + null, + null, + null, + null + ) + if (cursor != null && cursor.count > 0) { + cursor.moveToFirst() + for (i in 0 until cursor.count) { + vCard.add(getContactFromCursor(cursor)) + cursor.moveToNext() + } + } + val filename = DateFormat.format("yyyy-MM-dd_HH-mm-ss", Calendar.getInstance()).toString() + ".vcf" + Log_OC.d(TAG, "Storing: $filename") + val file = File(applicationContext.getCacheDir(), filename) + var fw: FileWriter? = null + try { + fw = FileWriter(file) + for (card in vCard) { + fw.write(card) + } + } catch (e: IOException) { + Log_OC.d(TAG, "Error ", e) + } finally { + cursor?.close() + if (fw != null) { + try { + fw.close() + } catch (e: IOException) { + Log_OC.d(TAG, "Error closing file writer ", e) + } + } + } + + val request = UploadRequest.Builder(user, file.absolutePath, backupFolder + file.name) + .setFileSize(file.length()) + .setNameConflicPolicy(NameCollisionPolicy.RENAME) + .setCreateRemoteFolder(true) + .setTrigger(UploadTrigger.USER) + .setPostAction(PostUploadAction.MOVE_TO_APP) + .setRequireWifi(false) + .setRequireCharging(false) + .build() + + val connection = TransferManagerConnection(applicationContext, user) + connection.enqueue(request) + } + + private fun expireFiles(daysToExpire: Int, backupFolderString: String, user: User) { + // -1 disables expiration + if (daysToExpire > -1) { + val storageManager = FileDataStorageManager( + user, + applicationContext.getContentResolver() + ) + val backupFolder: OCFile = storageManager.getFileByPath(backupFolderString) + val cal = Calendar.getInstance() + cal.add(Calendar.DAY_OF_YEAR, -daysToExpire) + val timestampToExpire = cal.timeInMillis + if (backupFolder != null) { + Log_OC.d(TAG, "expire: " + daysToExpire + " " + backupFolder.fileName) + } + val backups: List = storageManager.getFolderContent(backupFolder, false) + for (backup in backups) { + if (timestampToExpire > backup.modificationTimestamp) { + Log_OC.d(TAG, "delete " + backup.remotePath) + // delete backups + val service = Intent(applicationContext, OperationsService::class.java) + service.action = OperationsService.ACTION_REMOVE + service.putExtra(OperationsService.EXTRA_ACCOUNT, user.toPlatformAccount()) + service.putExtra(OperationsService.EXTRA_REMOTE_PATH, backup.remotePath) + service.putExtra(OperationsService.EXTRA_REMOVE_ONLY_LOCAL, false) + operationsServiceBinder!!.queueNewOperation(service) + } + } + } + operationsServiceConnection?.let { + applicationContext.unbindService(it) + } + } + + @Suppress("NestedBlockDepth") + private fun getContactFromCursor(cursor: Cursor): String { + val lookupKey = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.LOOKUP_KEY)) + val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey) + var vCard = "" + var inputStream: InputStream? = null + var inputStreamReader: InputStreamReader? = null + try { + inputStream = applicationContext.getContentResolver().openInputStream(uri) + val buffer = CharArray(BUFFER_SIZE) + val stringBuilder = StringBuilder() + if (inputStream != null) { + inputStreamReader = InputStreamReader(inputStream) + while (true) { + val byteCount = inputStreamReader.read(buffer, 0, buffer.size) + if (byteCount > 0) { + stringBuilder.append(buffer, 0, byteCount) + } else { + break + } + } + } + vCard = stringBuilder.toString() + // bump to vCard 3.0 format (min version supported by server) since Android OS exports to 2.1 + return Ezvcard.write(Ezvcard.parse(vCard).all()).version(VCardVersion.V3_0).go() + } catch (e: IOException) { + Log_OC.d(TAG, e.message) + } finally { + try { + inputStream?.close() + inputStreamReader?.close() + } catch (e: IOException) { + Log_OC.e(TAG, "failed to close stream") + } + } + return vCard + } + + /** + * Implements callback methods for service binding. + */ + private class OperationsServiceConnection internal constructor( + private val worker: ContactsBackupWork, + private val daysToExpire: Int, + private val backupFolder: String, + private val user: User + ) : ServiceConnection { + override fun onServiceConnected(component: ComponentName, service: IBinder) { + Log_OC.d(TAG, "service connected") + if (component == ComponentName(worker.applicationContext, OperationsService::class.java)) { + worker.operationsServiceBinder = service as OperationsServiceBinder + worker.expireFiles(daysToExpire, backupFolder, user) + } + } + + override fun onServiceDisconnected(component: ComponentName) { + Log_OC.d(TAG, "service disconnected") + if (component == ComponentName(worker.applicationContext, OperationsService::class.java)) { + worker.operationsServiceBinder = null + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/ContactsImportWork.kt b/app/src/main/java/com/nextcloud/client/jobs/ContactsImportWork.kt new file mode 100644 index 000000000000..2b231e491d6e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/ContactsImportWork.kt @@ -0,0 +1,158 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.ContactsContract +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.logger.Logger +import com.nextcloud.utils.extensions.toIntArray +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment +import com.owncloud.android.ui.fragment.contactsbackup.VCardComparator +import ezvcard.Ezvcard +import ezvcard.VCard +import org.apache.commons.io.FileUtils +import third_parties.ezvcard_android.ContactOperations +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.util.Collections +import java.util.TreeMap + +class ContactsImportWork( + appContext: Context, + params: WorkerParameters, + private val logger: Logger, + private val contentResolver: ContentResolver +) : Worker(appContext, params) { + + companion object { + const val TAG = "ContactsImportWork" + const val ACCOUNT_TYPE = "account_type" + const val ACCOUNT_NAME = "account_name" + const val VCARD_FILE_PATH = "vcard_file_path" + const val SELECTED_CONTACTS_FILE_PATH = "selected_contacts_file_path" + } + + @Suppress("ComplexMethod", "NestedBlockDepth", "LongMethod", "ReturnCount") // legacy code + override fun doWork(): Result { + val vCardFilePath = inputData.getString(VCARD_FILE_PATH) ?: "" + val contactsAccountName = inputData.getString(ACCOUNT_NAME) + val contactsAccountType = inputData.getString(ACCOUNT_TYPE) + val selectedContactsFilePath = inputData.getString(SELECTED_CONTACTS_FILE_PATH) + if (selectedContactsFilePath == null) { + Log_OC.d(TAG, "selectedContactsFilePath is null") + return Result.failure() + } + + val selectedContactsFile = File(selectedContactsFilePath) + if (!selectedContactsFile.exists()) { + Log_OC.d(TAG, "selectedContactsFile not exists") + return Result.failure() + } + + val selectedContactsIndices = readCheckedContractsFromFile(selectedContactsFile) + + val inputStream = BufferedInputStream(FileInputStream(vCardFilePath)) + val vCards = ArrayList() + + var cursor: Cursor? = null + @Suppress("TooGenericExceptionCaught") // legacy code + try { + val operations = ContactOperations(applicationContext, contactsAccountName, contactsAccountType) + vCards.addAll(Ezvcard.parse(inputStream).all()) + Collections.sort( + vCards, + VCardComparator() + ) + cursor = contentResolver.query( + ContactsContract.Contacts.CONTENT_URI, + null, + null, + null, + null + ) + val ownContactMap = TreeMap(VCardComparator()) + if (cursor != null && cursor.count > 0) { + cursor.moveToFirst() + for (i in 0 until cursor.count) { + val vCard = getContactFromCursor(cursor) + if (vCard != null) { + ownContactMap[vCard] = cursor.getLong(cursor.getColumnIndexOrThrow("NAME_RAW_CONTACT_ID")) + } + cursor.moveToNext() + } + } + + for (contactIndex in selectedContactsIndices) { + try { + val vCard = vCards[contactIndex] + if (BackupListFragment.getDisplayName(vCard).isEmpty()) { + if (!ownContactMap.containsKey(vCard)) { + operations.insertContact(vCard) + } else { + operations.updateContact(vCard, ownContactMap[vCard]) + } + } else { + operations.insertContact(vCard) // Insert All the contacts without name + } + } catch (t: Throwable) { + Log_OC.e(TAG, "skipping contactIndex: $contactIndex due to: $t") + } + } + } catch (e: Exception) { + logger.e(TAG, "${e.message}", e) + } finally { + cursor?.close() + } + + try { + inputStream.close() + } catch (e: IOException) { + logger.e(TAG, "Error closing vCard stream", e) + } + + Log_OC.d(TAG, "ContractsImportWork successfully completed") + selectedContactsFile.delete() + return Result.success() + } + + @Suppress("TooGenericExceptionCaught") + fun readCheckedContractsFromFile(file: File): IntArray = try { + val fileData = FileUtils.readFileToByteArray(file) + fileData.toIntArray() + } catch (e: Exception) { + Log_OC.e(TAG, "Exception readCheckedContractsFromFile: $e") + intArrayOf() + } + + private fun getContactFromCursor(cursor: Cursor): VCard? { + val lookupKey = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.LOOKUP_KEY)) + val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey) + var vCard: VCard? = null + try { + contentResolver.openInputStream(uri).use { inputStream -> + val vCardList = ArrayList() + vCardList.addAll(Ezvcard.parse(inputStream).all()) + if (vCardList.size > 0) { + vCard = vCardList[0] + } + } + } catch (e: IOException) { + logger.d(TAG, "${e.message}") + } + return vCard + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/ContentObserverWork.kt b/app/src/main/java/com/nextcloud/client/jobs/ContentObserverWork.kt new file mode 100644 index 000000000000..214b85239b43 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/ContentObserverWork.kt @@ -0,0 +1,126 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.autoUpload.AutoUploadHelper +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.FilesSyncHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * This work is triggered when OS detects change in media folders. + * + * It fires media detection worker and auto upload worker and finishes immediately. + * + */ +@Suppress("TooGenericExceptionCaught", "LongParameterList") +class ContentObserverWork( + context: Context, + private val params: WorkerParameters, + private val syncedFolderProvider: SyncedFolderProvider, + private val powerManagementService: PowerManagementService, + private val backgroundJobManager: BackgroundJobManager, + private val autoUploadHelper: AutoUploadHelper +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "🔍" + "ContentObserverWork" + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val workerName = BackgroundJobManagerImpl.formatClassTag(this@ContentObserverWork::class) + backgroundJobManager.logStartOfWorker(workerName) + Log_OC.d(TAG, "started") + + try { + if (params.triggeredContentUris.isNotEmpty()) { + Log_OC.d(TAG, "📸 content observer detected file changes.") + checkAndTriggerAutoUpload() + + // prevent worker fail because of another worker + try { + backgroundJobManager.startMediaFoldersDetectionJob() + } catch (e: Exception) { + Log_OC.d(TAG, "⚠️ media folder detection job failed :$e") + } + } else { + Log_OC.d(TAG, "⚠️ triggeredContentUris is empty — nothing to sync.") + } + + val result = Result.success() + backgroundJobManager.logEndOfWorker(workerName, result) + Log_OC.d(TAG, "finished") + result + } catch (e: Exception) { + Log_OC.e(TAG, "❌ Exception in ContentObserverWork: ${e.message}", e) + Result.retry() + } finally { + Log_OC.d(TAG, "🔄" + "re-scheduling job") + backgroundJobManager.scheduleContentObserverJob() + } + } + + private suspend fun checkAndTriggerAutoUpload() = withContext(Dispatchers.IO) { + if (powerManagementService.isPowerSavingEnabled) { + Log_OC.w(TAG, "⚡ Power saving mode active — skipping file sync.") + return@withContext + } + + val enabledFoldersCount = syncedFolderProvider.countEnabledSyncedFolders() + if (enabledFoldersCount <= 0) { + Log_OC.w(TAG, "🚫 No enabled synced folders found — skipping file sync.") + return@withContext + } + + val contentUris = params.triggeredContentUris.map { uri -> + // adds uri strings e.g. content://media/external/images/media/2281 + uri.toString() + }.toTypedArray() + + Log_OC.d(TAG, "📄 Content uris detected") + + // insert entries based on content uris + try { + syncedFolderProvider.syncedFolders + .filter { it.isEnabled } + .forEach { folder -> + val inserted = if (contentUris.isEmpty()) { + Log_OC.d(TAG, "inserting all entries") + autoUploadHelper.insertEntries(folder) + true + } else { + Log_OC.d(TAG, "inserting changed entries") + autoUploadHelper.insertChangedEntries(folder, contentUris) + } + + if (!inserted) { + Log_OC.w( + TAG, + "changed content uris not stored, fallback to insert all db entries to not lose files" + ) + autoUploadHelper.insertEntries(folder) + } + } + + FilesSyncHelper.startAutoUploadForEnabledSyncedFolders( + syncedFolderProvider, + backgroundJobManager, + false + ) + Log_OC.d(TAG, "✅ auto upload triggered successfully for ${contentUris.size} file(s).") + } catch (e: Exception) { + Log_OC.e(TAG, "❌ Failed to start auto upload for changed files: ${e.message}", e) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt b/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt new file mode 100644 index 000000000000..de69651c67e0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt @@ -0,0 +1,169 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.app.DownloadManager +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.jobs.worker.WorkerFilesPayload +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.operations.DownloadType +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.FileExportUtils +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class FilesExportWork( + private val context: Context, + private val user: User, + private val contentResolver: ContentResolver, + private val viewThemeUtils: ViewThemeUtils, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val NOTIFICATION_ID = 179 + const val FILES_TO_DOWNLOAD = "files_to_download" + private val TAG = FilesExportWork::class.simpleName + } + + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + override suspend fun doWork(): Result { + val path = inputData.getString(FILES_TO_DOWNLOAD) + val fileIds = WorkerFilesPayload.read(path) + if (fileIds.isEmpty()) { + Log_OC.w(TAG, "file export was started without any file") + WorkerFilesPayload.cleanup(path) + return Result.success() + } + + val storageManager = FileDataStorageManager(user, contentResolver) + + try { + val (succeeded, failed) = exportFiles(fileIds, storageManager) + notificationManager.cancel(NOTIFICATION_ID) + showSummaryNotification(succeeded, failed) + } finally { + WorkerFilesPayload.cleanup(path) + } + + return Result.success() + } + + @Suppress("DEPRECATION") + private suspend fun exportFiles(fileIDs: List, storageManager: FileDataStorageManager): Pair = + withContext(Dispatchers.IO) { + val client = runCatching { + OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(user.toOwnCloudAccount(), context) + }.onFailure { + Log_OC.e(TAG, "Failed to create OwnCloudClient", it) + }.getOrNull() + + val fileExportUtils = FileExportUtils() + val files = fileIDs.mapNotNull { storageManager.getFileById(it) } + val total = files.size + var succeeded = 0 + var failed = 0 + + files.forEachIndexed { index, ocFile -> + showProgressNotification(index, total, ocFile.fileName) + + val exported = when { + !FileStorageUtils.checkIfEnoughSpace(ocFile) -> false + + ocFile.isDown -> runCatching { + fileExportUtils.exportFile(ocFile.fileName, ocFile.mimeType, contentResolver, ocFile, null) + }.onFailure { Log_OC.e(TAG, "Error exporting file", it) }.isSuccess + + client != null -> downloadFile(ocFile, client) + + else -> { + Log_OC.e(TAG, "Skipping download, client unavailable: ${ocFile.remotePath}") + false + } + } + + if (exported) succeeded++ else failed++ + } + + return@withContext succeeded to failed + } + + @Suppress("DEPRECATION") + private suspend fun downloadFile(file: OCFile, client: OwnCloudClient): Boolean = withContext(Dispatchers.IO) { + val operation = DownloadFileOperation(user, file, context) + operation.downloadType = DownloadType.EXPORT + return@withContext runCatching { + operation.execute(client)?.isSuccess == true + }.onFailure { + Log_OC.e(TAG, "Exception downloading file: ${file.remotePath}", it) + }.getOrDefault(false) + } + + private fun showProgressNotification(current: Int, total: Int, fileName: String) { + val title = context.getString(R.string.export_in_progress, current + 1, total) + + val notification = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle(title) + .setContentText(fileName) + .setProgress(total, current + 1, false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .also { viewThemeUtils.androidx.themeNotificationCompatBuilder(context, it) } + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun showSummaryNotification(succeeded: Int, failed: Int) { + val resources = context.resources + val message = when { + failed == 0 -> resources.getQuantityString(R.plurals.export_successful, succeeded, succeeded) + succeeded == 0 -> resources.getQuantityString(R.plurals.export_failed, failed, failed) + else -> resources.getQuantityString(R.plurals.export_partially_failed, succeeded, succeeded) + } + + val pendingIntent = PendingIntent.getActivity( + context, + NOTIFICATION_ID, + Intent(DownloadManager.ACTION_VIEW_DOWNLOADS).apply { flags = FLAG_ACTIVITY_NEW_TASK }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle(message) + .setAutoCancel(true) + .addAction(NotificationCompat.Action(null, context.getString(R.string.locate_folder), pendingIntent)) + .also { viewThemeUtils.androidx.themeNotificationCompatBuilder(context, it) } + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/HealthStatusWork.kt b/app/src/main/java/com/nextcloud/client/jobs/HealthStatusWork.kt new file mode 100644 index 000000000000..2e0fadbf73d3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/HealthStatusWork.kt @@ -0,0 +1,121 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.UploadResult +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.Problem +import com.owncloud.android.lib.resources.status.SendClientDiagnosticRemoteOperation +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.theme.CapabilityUtils + +class HealthStatusWork( + private val context: Context, + params: WorkerParameters, + private val userAccountManager: UserAccountManager, + private val arbitraryDataProvider: ArbitraryDataProvider, + private val backgroundJobManager: BackgroundJobManager +) : Worker(context, params) { + override fun doWork(): Result { + backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class)) + + for (user in userAccountManager.allUsers) { + // only if security guard is enabled + if (!CapabilityUtils.getCapability(user, context).securityGuard.isTrue) { + continue + } + + val syncConflicts = collectSyncConflicts(user) + + val problems = mutableListOf().apply { + addAll( + collectUploadProblems( + user, + listOf( + UploadResult.CREDENTIAL_ERROR, + UploadResult.CANNOT_CREATE_FILE, + UploadResult.FOLDER_ERROR, + UploadResult.SERVICE_INTERRUPTED + ) + ) + ) + } + + val virusDetected = collectUploadProblems(user, listOf(UploadResult.VIRUS_DETECTED)).firstOrNull() + + val e2eErrors = EncryptionUtils.readE2eError(arbitraryDataProvider, user) + + val nextcloudClient = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(user.toOwnCloudAccount(), context) + val result = + SendClientDiagnosticRemoteOperation( + syncConflicts, + problems, + virusDetected, + e2eErrors + ).execute( + nextcloudClient + ) + + if (!result.isSuccess) { + if (result.exception == null) { + Log_OC.e(TAG, "Update client health NOT successful!") + } else { + Log_OC.e(TAG, "Update client health NOT successful!", result.exception) + } + } + } + + val result = Result.success() + backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result) + return result + } + + private fun collectSyncConflicts(user: User): Problem? { + val fileDataStorageManager = FileDataStorageManager(user, context.contentResolver) + + val conflicts = fileDataStorageManager.getFilesWithSyncConflict(user) + + return if (conflicts.isEmpty()) { + null + } else { + Problem("sync_conflicts", conflicts.size, conflicts.minOf { it.lastSyncDateForData }) + } + } + + private fun collectUploadProblems(user: User, errorCodes: List): List { + val uploadsStorageManager = UploadsStorageManager(userAccountManager, context.contentResolver) + + val problems = uploadsStorageManager + .getUploadsForAccount(user.accountName) + .filter { + errorCodes.contains(it.lastResult) + }.groupBy { it.lastResult } + + return if (problems.isEmpty()) { + emptyList() + } else { + return problems.map { problem -> + Problem(problem.key.toString(), problem.value.size, problem.value.minOf { it.uploadEndTimestamp }) + } + } + } + + companion object { + private const val TAG = "Health Status" + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/InternalTwoWaySyncWork.kt b/app/src/main/java/com/nextcloud/client/jobs/InternalTwoWaySyncWork.kt new file mode 100644 index 000000000000..1d1c89fd9d3f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/InternalTwoWaySyncWork.kt @@ -0,0 +1,136 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.SynchronizeFolderOperation +import com.owncloud.android.utils.FileStorageUtils +import java.io.File + +@Suppress("Detekt.NestedBlockDepth", "ReturnCount", "LongParameterList") +class InternalTwoWaySyncWork( + private val context: Context, + params: WorkerParameters, + private val userAccountManager: UserAccountManager, + private val powerManagementService: PowerManagementService, + private val connectivityService: ConnectivityService, + private val appPreferences: AppPreferences +) : Worker(context, params) { + private var shouldRun = true + private var operation: SynchronizeFolderOperation? = null + + override fun doWork(): Result { + Log_OC.d(TAG, "Worker started!") + + var result = true + + @Suppress("ComplexCondition") + if (!appPreferences.isTwoWaySyncEnabled || + powerManagementService.isPowerSavingEnabled || + !connectivityService.isConnected || + connectivityService.isInternetWalled || + !connectivityService.connectivity.isWifi + ) { + Log_OC.d(TAG, "Not starting due to constraints!") + return Result.success() + } + + val users = userAccountManager.allUsers + + for (user in users) { + val fileDataStorageManager = FileDataStorageManager(user, context.contentResolver) + val folders = fileDataStorageManager.getInternalTwoWaySyncFolders(user) + + for (folder in folders) { + if (!shouldRun) { + Log_OC.d(TAG, "Worker was stopped!") + return Result.failure() + } + + checkFreeSpace(folder)?.let { checkFreeSpaceResult -> + return checkFreeSpaceResult + } + + Log_OC.d(TAG, "Folder ${folder.remotePath}: started!") + operation = SynchronizeFolderOperation(context, folder.remotePath, user, fileDataStorageManager, true) + val operationResult = operation?.execute(context) + + if (operationResult?.isSuccess == true) { + Log_OC.d(TAG, "Folder ${folder.remotePath}: finished!") + } else { + Log_OC.d(TAG, "Folder ${folder.remotePath} failed!") + result = false + } + + folder.apply { + operationResult?.let { + internalFolderSyncResult = it.code.toString() + } + + internalFolderSyncTimestamp = System.currentTimeMillis() + } + + fileDataStorageManager.saveFile(folder) + } + } + + return if (result) { + Log_OC.d(TAG, "Worker finished with success!") + Result.success() + } else { + Log_OC.d(TAG, "Worker finished with failure!") + Result.failure() + } + } + + override fun onStopped() { + Log_OC.d(TAG, "OnStopped of worker called!") + operation?.cancel() + shouldRun = false + super.onStopped() + } + + @Suppress("TooGenericExceptionCaught") + private fun checkFreeSpace(folder: OCFile): Result? { + val storagePath = folder.storagePath ?: MainApp.getStoragePath() + val file = File(storagePath) + + if (!file.exists()) return null + + return try { + val freeSpaceLeft = file.freeSpace + val localFolder = File(storagePath, MainApp.getDataFolder()) + val localFolderSize = FileStorageUtils.getFolderSize(localFolder) + val remoteFolderSize = folder.fileLength + + if (freeSpaceLeft < (remoteFolderSize - localFolderSize)) { + Log_OC.d(TAG, "Not enough space left!") + Result.failure() + } else { + null + } + } catch (e: Exception) { + Log_OC.d(TAG, "Error caught at checkFreeSpace: $e") + null + } + } + + companion object { + const val TAG = "InternalTwoWaySyncWork" + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/JobInfo.kt b/app/src/main/java/com/nextcloud/client/jobs/JobInfo.kt new file mode 100644 index 000000000000..b23b9331d2b2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/JobInfo.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import java.util.Date +import java.util.UUID + +data class JobInfo( + val id: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000"), + val state: String = "", + val name: String = "", + val user: String = "", + val workerClass: String = "", + val started: Date = Date(0), + val progress: Int = 0 +) + +data class LogEntry( + val started: Date? = null, + val finished: Date? = null, + val result: String? = null, + var workerClass: String = BackgroundJobManagerImpl.NOT_SET_VALUE +) diff --git a/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt b/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt new file mode 100644 index 000000000000..3f331453c7a1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.Context +import android.content.ContextWrapper +import androidx.work.Configuration +import androidx.work.WorkManager +import com.nextcloud.client.core.Clock +import com.nextcloud.client.preferences.AppPreferences +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +class JobsModule { + + @Provides + @Singleton + fun workManager(context: Context, factory: BackgroundJobFactory): WorkManager { + val configuration = Configuration.Builder() + .setWorkerFactory(factory) + .build() + + val contextWrapper = object : ContextWrapper(context) { + override fun getApplicationContext(): Context = this + } + + WorkManager.initialize(contextWrapper, configuration) + return WorkManager.getInstance(context) + } + + @Provides + @Singleton + fun backgroundJobManager( + workManager: WorkManager, + clock: Clock, + preferences: AppPreferences + ): BackgroundJobManager = BackgroundJobManagerImpl(workManager, clock, preferences) +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/MediaFoldersDetectionWork.kt b/app/src/main/java/com/nextcloud/client/jobs/MediaFoldersDetectionWork.kt new file mode 100644 index 000000000000..198d37f18f12 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/MediaFoldersDetectionWork.kt @@ -0,0 +1,285 @@ +/* + * Nextcloud Android client application + * + * @author Mario Danic + * @author Andy Scherzinger + * @author Chris Narkiewicz + * Copyright (C) 2018 Mario Danic + * Copyright (C) 2018 Andy Scherzinger + * Copyright (C) 2020 Chris Narkiewicz + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.app.Activity +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.text.TextUtils +import androidx.core.app.NotificationCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.google.gson.Gson +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.core.Clock +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.preferences.AppPreferencesImpl +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.MediaFoldersModel +import com.owncloud.android.datamodel.MediaProvider +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.ManageAccountsActivity +import com.owncloud.android.ui.activity.SyncedFoldersActivity +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.SyncedFolderUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.util.Random + +@Suppress("LongParameterList") // dependencies injection +class MediaFoldersDetectionWork( + private val context: Context, + params: WorkerParameters, + private val resources: Resources, + private val contentResolver: ContentResolver, + private val userAccountManager: UserAccountManager, + private val preferences: AppPreferences, + private val clock: Clock, + private val viewThemeUtils: ViewThemeUtils, + private val syncedFolderProvider: SyncedFolderProvider +) : Worker(context, params) { + + companion object { + const val TAG = "MediaFoldersDetectionJob" + const val KEY_MEDIA_FOLDER_PATH = "KEY_MEDIA_FOLDER_PATH" + const val KEY_MEDIA_FOLDER_TYPE = "KEY_MEDIA_FOLDER_TYPE" + private const val ACCOUNT_NAME_GLOBAL = "global" + private const val KEY_MEDIA_FOLDERS = "media_folders" + const val NOTIFICATION_ID = "NOTIFICATION_ID" + private val DISABLE_DETECTION_CLICK = MainApp.getAuthority() + "_DISABLE_DETECTION_CLICK" + } + + private val randomIdGenerator = Random(clock.currentTime) + + @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "ReturnCount") // legacy code + override fun doWork(): Result { + val arbitraryDataProvider: ArbitraryDataProvider = ArbitraryDataProviderImpl(context) + val gson = Gson() + val mediaFoldersModel: MediaFoldersModel + val imageMediaFolders = MediaProvider.getImageFolders( + contentResolver, + 1, + null, + true + ) + val videoMediaFolders = MediaProvider.getVideoFolders( + contentResolver, + 1, + null, + true + ) + val imageMediaFolderPaths: MutableList = ArrayList() + val videoMediaFolderPaths: MutableList = ArrayList() + + for (imageMediaFolder in imageMediaFolders) { + imageMediaFolder.absolutePath?.let { + imageMediaFolderPaths.add(it) + } + } + + for (videoMediaFolder in videoMediaFolders) { + videoMediaFolder.absolutePath?.let { + imageMediaFolderPaths.add(it) + } + } + + val arbitraryDataString = arbitraryDataProvider.getValue(ACCOUNT_NAME_GLOBAL, KEY_MEDIA_FOLDERS) + if (!TextUtils.isEmpty(arbitraryDataString)) { + mediaFoldersModel = gson.fromJson(arbitraryDataString, MediaFoldersModel::class.java) + // merge new detected paths with already notified ones + for (existingImageFolderPath in mediaFoldersModel.imageMediaFolders) { + if (!imageMediaFolderPaths.contains(existingImageFolderPath)) { + imageMediaFolderPaths.add(existingImageFolderPath) + } + } + for (existingVideoFolderPath in mediaFoldersModel.videoMediaFolders) { + if (!videoMediaFolderPaths.contains(existingVideoFolderPath)) { + videoMediaFolderPaths.add(existingVideoFolderPath) + } + } + // Store updated values + arbitraryDataProvider.storeOrUpdateKeyValue( + ACCOUNT_NAME_GLOBAL, + KEY_MEDIA_FOLDERS, + gson.toJson(MediaFoldersModel(imageMediaFolderPaths, videoMediaFolderPaths)) + ) + if (preferences.isShowMediaScanNotifications) { + imageMediaFolderPaths.removeAll(mediaFoldersModel.imageMediaFolders) + videoMediaFolderPaths.removeAll(mediaFoldersModel.videoMediaFolders) + if (imageMediaFolderPaths.isNotEmpty() || videoMediaFolderPaths.isNotEmpty()) { + val allUsers = userAccountManager.allUsers + val activeUsers: MutableList = ArrayList() + for (user in allUsers) { + if (!arbitraryDataProvider.getBooleanValue(user, ManageAccountsActivity.PENDING_FOR_REMOVAL)) { + activeUsers.add(user) + } + } + for (user in activeUsers) { + for (imageMediaFolder in imageMediaFolderPaths) { + val folder = syncedFolderProvider.findByLocalPathAndAccount( + imageMediaFolder, + user + ) + if (folder == null && + SyncedFolderUtils.isQualifyingMediaFolder(imageMediaFolder, MediaFolderType.IMAGE) + ) { + val contentTitle = String.format( + resources.getString(R.string.new_media_folder_detected), + resources.getString(R.string.new_media_folder_photos) + ) + sendNotification( + contentTitle, + imageMediaFolder.substring(imageMediaFolder.lastIndexOf('/') + 1), + user, + imageMediaFolder, + MediaFolderType.IMAGE.id + ) + } + } + for (videoMediaFolder in videoMediaFolderPaths) { + val folder = syncedFolderProvider.findByLocalPathAndAccount( + videoMediaFolder, + user + ) + if (folder == null) { + val contentTitle = String.format( + context.getString(R.string.new_media_folder_detected), + context.getString(R.string.new_media_folder_videos) + ) + sendNotification( + contentTitle, + videoMediaFolder.substring(videoMediaFolder.lastIndexOf('/') + 1), + user, + videoMediaFolder, + MediaFolderType.VIDEO.id + ) + } + } + } + } + } + } else { + mediaFoldersModel = MediaFoldersModel(imageMediaFolderPaths, videoMediaFolderPaths) + arbitraryDataProvider.storeOrUpdateKeyValue( + ACCOUNT_NAME_GLOBAL, + KEY_MEDIA_FOLDERS, + gson.toJson(mediaFoldersModel) + ) + } + + return Result.success() + } + + @Suppress("LongMethod") + private fun sendNotification(contentTitle: String, subtitle: String, user: User, path: String, type: Int) { + val notificationId = randomIdGenerator.nextInt() + + val intent = Intent(context, SyncedFoldersActivity::class.java).apply { + putExtra(NOTIFICATION_ID, notificationId) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(NotificationWork.KEY_NOTIFICATION_ACCOUNT, user.accountName) + putExtra(KEY_MEDIA_FOLDER_PATH, path) + putExtra(KEY_MEDIA_FOLDER_TYPE, type) + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + val notificationBuilder = NotificationCompat.Builder( + context, + NotificationUtils.NOTIFICATION_CHANNEL_GENERAL + ) + .setSmallIcon(R.drawable.notification_icon) + .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon)) + .setSubText(user.accountName) + .setContentTitle(contentTitle) + .setContentText(subtitle) + .setAutoCancel(true) + .setSound(null) + .setVibrate(null) + .setSilent(true) + .setContentIntent(pendingIntent) + + viewThemeUtils.androidx.themeNotificationCompatBuilder(context, notificationBuilder) + + val disableDetection = Intent(context, NotificationReceiver::class.java).apply { + putExtra(NOTIFICATION_ID, notificationId) + action = DISABLE_DETECTION_CLICK + } + + val disableIntent = PendingIntent.getBroadcast( + context, + notificationId, + disableDetection, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + notificationBuilder.addAction( + NotificationCompat.Action( + R.drawable.ic_close, + context.getString(R.string.disable_new_media_folder_detection_notifications), + disableIntent + ) + ) + + val configureIntent = PendingIntent.getActivity( + context, + notificationId, + intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + notificationBuilder.addAction( + NotificationCompat.Action( + R.drawable.ic_settings, + context.getString(R.string.configure_new_media_folder_detection_notifications), + configureIntent + ) + ) + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + class NotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + val notificationId = intent.getIntExtra(NOTIFICATION_ID, 0) + val preferences = AppPreferencesImpl.fromContext(context) + if (DISABLE_DETECTION_CLICK == action) { + Log_OC.d(this, "Disable media scan notifications") + preferences.isShowMediaScanNotifications = false + cancel(context, notificationId) + } + } + + private fun cancel(context: Context, notificationId: Int) { + val notificationManager = context.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(notificationId) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt b/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt new file mode 100644 index 000000000000..b852fea8176e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt @@ -0,0 +1,356 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.Manifest +import android.accounts.AuthenticatorException +import android.accounts.OperationCanceledException +import android.app.Activity +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.media.RingtoneManager +import android.text.TextUtils +import android.util.Base64 +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.google.gson.Gson +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.integrations.deck.DeckApi +import com.owncloud.android.R +import com.owncloud.android.datamodel.DecryptedPushMessage +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.notifications.DeleteNotificationRemoteOperation +import com.owncloud.android.lib.resources.notifications.GetNotificationRemoteOperation +import com.owncloud.android.lib.resources.notifications.models.Notification +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.navigation.NavigatorActivity +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.PushUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import dagger.android.AndroidInjection +import org.apache.commons.httpclient.HttpMethod +import org.apache.commons.httpclient.HttpStatus +import org.apache.commons.httpclient.methods.DeleteMethod +import org.apache.commons.httpclient.methods.GetMethod +import org.apache.commons.httpclient.methods.PutMethod +import org.apache.commons.httpclient.methods.Utf8PostMethod +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.PrivateKey +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.inject.Inject + +@Suppress("LongParameterList") +class NotificationWork constructor( + private val context: Context, + params: WorkerParameters, + private val notificationManager: NotificationManager, + private val accountManager: UserAccountManager, + private val deckApi: DeckApi, + private val viewThemeUtils: ViewThemeUtils +) : Worker(context, params) { + + companion object { + const val TAG = "NotificationJob" + const val KEY_NOTIFICATION_ACCOUNT = "KEY_NOTIFICATION_ACCOUNT" + const val KEY_NOTIFICATION_SUBJECT = "subject" + const val KEY_NOTIFICATION_SIGNATURE = "signature" + private const val KEY_NOTIFICATION_ACTION_LINK = "KEY_NOTIFICATION_ACTION_LINK" + private const val KEY_NOTIFICATION_ACTION_TYPE = "KEY_NOTIFICATION_ACTION_TYPE" + private const val PUSH_NOTIFICATION_ID = "PUSH_NOTIFICATION_ID" + private const val NUMERIC_NOTIFICATION_ID = "NUMERIC_NOTIFICATION_ID" + } + + @Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "ComplexMethod", "LongMethod") // legacy code + override fun doWork(): Result { + val subject = inputData.getString(KEY_NOTIFICATION_SUBJECT) ?: "" + val signature = inputData.getString(KEY_NOTIFICATION_SIGNATURE) ?: "" + if (!TextUtils.isEmpty(subject) && !TextUtils.isEmpty(signature)) { + try { + val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT) + val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT) + val privateKey = PushUtils.readKeyFromFile(false) as PrivateKey + try { + val signatureVerification = PushUtils.verifySignature( + context, + accountManager, + base64DecodedSignature, + base64DecodedSubject + ) + if (signatureVerification != null && signatureVerification.signatureValid) { + val cipher = Cipher.getInstance("RSA/None/PKCS1Padding") + cipher.init(Cipher.DECRYPT_MODE, privateKey) + val decryptedSubject = cipher.doFinal(base64DecodedSubject) + val gson = Gson() + val decryptedPushMessage = gson.fromJson( + String(decryptedSubject), + DecryptedPushMessage::class.java + ) + if (decryptedPushMessage.delete) { + notificationManager.cancel(decryptedPushMessage.nid) + } else if (decryptedPushMessage.deleteAll) { + notificationManager.cancelAll() + } else { + val user = accountManager.getUser(signatureVerification.account?.name) + .orElseThrow { RuntimeException() } + fetchCompleteNotification(user, decryptedPushMessage) + } + } + } catch (e1: GeneralSecurityException) { + Log_OC.d(TAG, "Error decrypting message ${e1.javaClass.name} ${e1.localizedMessage}") + } + } catch (exception: Exception) { + Log_OC.d(TAG, "Something went very wrong" + exception.localizedMessage) + } + } + return Result.success() + } + + @Suppress("LongMethod") // legacy code + private fun sendNotification(notification: Notification, user: User) { + val randomId = SecureRandom() + val file = notification.subjectRichParameters["file"] + + val deckActionOverrideIntent = deckApi.createForwardToDeckActionIntent(notification, user) + + val pendingIntent: PendingIntent? + if (deckActionOverrideIntent.isPresent) { + pendingIntent = deckActionOverrideIntent.get() + } else { + val intent: Intent + if (file == null) { + intent = Intent(context, NavigatorActivity::class.java) + } else { + intent = Intent(context, FileDisplayActivity::class.java) + intent.action = Intent.ACTION_VIEW + intent.putExtra(FileDisplayActivity.KEY_FILE_ID, file.id) + } + intent.putExtra(KEY_NOTIFICATION_ACCOUNT, user.accountName) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + pendingIntent = PendingIntent.getActivity( + context, + notification.getNotificationId(), + intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + } + + val pushNotificationId = randomId.nextInt() + val notificationBuilder = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_PUSH) + .setSmallIcon(R.drawable.notification_icon) + .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon)) + .setShowWhen(true) + .setSubText(user.accountName) + .setContentTitle(notification.getSubject()) + .setContentText(notification.getMessage()) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .setAutoCancel(true) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setContentIntent(pendingIntent) + + viewThemeUtils.androidx.themeNotificationCompatBuilder(context, notificationBuilder) + + // Remove + if (notification.getActions().isEmpty()) { + val disableDetection = Intent(context, NotificationReceiver::class.java) + disableDetection.putExtra(NUMERIC_NOTIFICATION_ID, notification.getNotificationId()) + disableDetection.putExtra(PUSH_NOTIFICATION_ID, pushNotificationId) + disableDetection.putExtra(KEY_NOTIFICATION_ACCOUNT, user.accountName) + val disableIntent = PendingIntent.getBroadcast( + context, + pushNotificationId, + disableDetection, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + notificationBuilder.addAction( + NotificationCompat.Action( + R.drawable.ic_close, + context.getString(R.string.remove_push_notification), + disableIntent + ) + ) + } else { + // Actions + for (action in notification.getActions()) { + val actionIntent = Intent(context, NotificationReceiver::class.java) + actionIntent.putExtra(NUMERIC_NOTIFICATION_ID, notification.getNotificationId()) + actionIntent.putExtra(PUSH_NOTIFICATION_ID, pushNotificationId) + actionIntent.putExtra(KEY_NOTIFICATION_ACCOUNT, user.accountName) + actionIntent.putExtra(KEY_NOTIFICATION_ACTION_LINK, action.link) + actionIntent.putExtra(KEY_NOTIFICATION_ACTION_TYPE, action.type) + val actionPendingIntent = PendingIntent.getBroadcast( + context, + randomId.nextInt(), + actionIntent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + var icon: Int + icon = if (action.primary) { + R.drawable.ic_check_circle + } else { + R.drawable.ic_check_circle_outline + } + notificationBuilder.addAction(NotificationCompat.Action(icon, action.label, actionPendingIntent)) + } + } + notificationBuilder.setPublicVersion( + NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_PUSH) + .setSmallIcon(R.drawable.notification_icon) + .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon)) + .setShowWhen(true) + .setSubText(user.accountName) + .setContentTitle(context.getString(R.string.new_notification)) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .setAutoCancel(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(pendingIntent) + .also { + viewThemeUtils.androidx.themeNotificationCompatBuilder(context, it) + } + .build() + ) + + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Log_OC.w(this, "Missing permission to post notifications") + } else { + val notificationManager = NotificationManagerCompat.from(context) + notificationManager.notify(notification.getNotificationId(), notificationBuilder.build()) + } + } + + @Suppress("TooGenericExceptionCaught") // legacy code + private fun fetchCompleteNotification(account: User, decryptedPushMessage: DecryptedPushMessage) { + val optionalUser = accountManager.getUser(account.accountName) + if (!optionalUser.isPresent) { + Log_OC.e(this, "Account may not be null") + return + } + val user = optionalUser.get() + try { + val client = OwnCloudClientFactory.createNextcloudClient(user, context) + val result = GetNotificationRemoteOperation(decryptedPushMessage.nid) + .execute(client) + if (result.isSuccess) { + val notification = result.resultData + sendNotification(notification, account) + } + } catch (e: Exception) { + Log_OC.e(this, "Error creating account", e) + } + } + + class NotificationReceiver : BroadcastReceiver() { + private lateinit var accountManager: UserAccountManager + + /** + * This is a workaround for a Dagger compiler bug - it cannot inject + * into a nested Kotlin class for some reason, but the helper + * works. + */ + @Inject + fun inject(accountManager: UserAccountManager) { + this.accountManager = accountManager + } + + @Suppress("ComplexMethod") // legacy code + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + val numericNotificationId = intent.getIntExtra(NUMERIC_NOTIFICATION_ID, 0) + val accountName = intent.getStringExtra(KEY_NOTIFICATION_ACCOUNT) + if (numericNotificationId != 0) { + Thread( + Runnable { + val notificationManager = context.getSystemService( + Activity.NOTIFICATION_SERVICE + ) as NotificationManager + var oldNotification: android.app.Notification? = null + for (statusBarNotification in notificationManager.activeNotifications) { + if (numericNotificationId == statusBarNotification.id) { + oldNotification = statusBarNotification.notification + break + } + } + cancel(context, numericNotificationId) + try { + val optionalUser = accountManager.getUser(accountName) + if (optionalUser.isPresent) { + val user = optionalUser.get() + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(user.toOwnCloudAccount(), context) + val nextcloudClient = OwnCloudClientFactory.createNextcloudClient(user, context) + val actionType = intent.getStringExtra(KEY_NOTIFICATION_ACTION_TYPE) + val actionLink = intent.getStringExtra(KEY_NOTIFICATION_ACTION_LINK) + val success: Boolean = if (!actionType.isNullOrEmpty() && !actionLink.isNullOrEmpty()) { + val resultCode = executeAction(actionType, actionLink, client) + resultCode == HttpStatus.SC_OK || resultCode == HttpStatus.SC_ACCEPTED + } else { + DeleteNotificationRemoteOperation(numericNotificationId) + .execute(nextcloudClient).isSuccess + } + if (success) { + if (oldNotification == null) { + cancel(context, numericNotificationId) + } + } else { + notificationManager.notify(numericNotificationId, oldNotification) + } + } + } catch (e: IOException) { + Log_OC.e(TAG, "Error initializing client", e) + } catch (e: OperationCanceledException) { + Log_OC.e(TAG, "Error initializing client", e) + } catch (e: AuthenticatorException) { + Log_OC.e(TAG, "Error initializing client", e) + } + } + ).start() + } + } + + @Suppress("ReturnCount") // legacy code + private fun executeAction(actionType: String, actionLink: String, client: OwnCloudClient): Int { + val method: HttpMethod + method = when (actionType) { + "GET" -> GetMethod(actionLink) + "POST" -> Utf8PostMethod(actionLink) + "DELETE" -> DeleteMethod(actionLink) + "PUT" -> PutMethod(actionLink) + else -> return 0 // do nothing + } + method.setRequestHeader(RemoteOperation.OCS_API_HEADER, RemoteOperation.OCS_API_HEADER_VALUE) + try { + return client.executeMethod(method) + } catch (e: IOException) { + Log_OC.e(TAG, "Execution of notification action failed: $e") + } + return 0 + } + + private fun cancel(context: Context, notificationId: Int) { + val notificationManager = context.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(notificationId) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/OfflineSyncWork.kt b/app/src/main/java/com/nextcloud/client/jobs/OfflineSyncWork.kt new file mode 100644 index 000000000000..2c0e0ebf91a1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/OfflineSyncWork.kt @@ -0,0 +1,145 @@ +/* + * Nextcloud Android client application + * + * @author Mario Danic + * @author Chris Narkiewicz + * Copyright (C) 2018 Mario Danic + * Copyright (C) 2020 Chris Narkiewicz + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.ContentResolver +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.network.ConnectivityService +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.CheckEtagRemoteOperation +import com.owncloud.android.operations.SynchronizeFileOperation +import com.owncloud.android.utils.FileStorageUtils +import java.io.File + +@Suppress("LongParameterList") // Legacy code +class OfflineSyncWork( + private val context: Context, + params: WorkerParameters, + private val contentResolver: ContentResolver, + private val userAccountManager: UserAccountManager, + private val connectivityService: ConnectivityService, + private val powerManagementService: PowerManagementService +) : Worker(context, params) { + + companion object { + const val TAG = "OfflineSyncJob" + } + + override fun doWork(): Result { + if (!powerManagementService.isPowerSavingEnabled) { + val users = userAccountManager.allUsers + for (user in users) { + val storageManager = FileDataStorageManager(user, contentResolver) + val ocRoot = storageManager.getFileByPath(OCFile.ROOT_PATH) + if (ocRoot.storagePath == null) { + break + } + recursive(File(ocRoot.storagePath), storageManager, user) + } + } + return Result.success() + } + + private fun recursive(folder: File, storageManager: FileDataStorageManager, user: User) { + val downloadFolder = FileStorageUtils.getSavePath(user.accountName) + val folderName = folder.absolutePath.replaceFirst(downloadFolder.toRegex(), "") + OCFile.PATH_SEPARATOR + Log_OC.d(TAG, "$folderName: enter") + // exit + if (folder.listFiles() == null) { + return + } + + val updatedEtag = checkETagChanged(folderName, storageManager, user) ?: return + + // iterate over downloaded files + val files = folder.listFiles { obj: File -> obj.isFile } + if (files != null) { + for (file in files) { + val ocFile = storageManager.getFileByLocalPath(file.path) + val synchronizeFileOperation = SynchronizeFileOperation( + ocFile?.remotePath, + user, + true, + context, + storageManager, + true, + false + ) + synchronizeFileOperation.execute(context) + } + } + // recursive into folder + val subfolders = folder.listFiles { obj: File -> obj.isDirectory } + if (subfolders != null) { + for (subfolder in subfolders) { + recursive(subfolder, storageManager, user) + } + } + // update eTag + @Suppress("TooGenericExceptionCaught") // legacy code + try { + val ocFolder = storageManager.getFileByPath(folderName) + ocFolder.etagOnServer = updatedEtag + storageManager.saveFile(ocFolder) + } catch (e: Exception) { + Log_OC.e(TAG, "Failed to update etag on " + folder.absolutePath, e) + } + } + + /** + * @return new eTag if changed, `null` otherwise + */ + private fun checkETagChanged(folderName: String, storageManager: FileDataStorageManager, user: User): String? { + val folder = storageManager.getFileByEncryptedRemotePath(folderName) ?: return null + + Log_OC.d(TAG, "$folderName: current eTag: ${folder.etag}") + + // check for etag change, if false, skip + val operation = CheckEtagRemoteOperation(folder.remotePath, folder.etagOnServer) + val result = operation.execute(user, context) + + return when (result.code) { + ResultCode.ETAG_UNCHANGED -> { + Log_OC.d(TAG, "$folderName: eTag unchanged") + null + } + + ResultCode.FILE_NOT_FOUND -> { + val removalResult = storageManager.removeFolder(folder, true, true) + if (!removalResult) { + Log_OC.e(TAG, "removal of " + folder.storagePath + " failed: file not found") + } + null + } + + ResultCode.ETAG_CHANGED -> { + Log_OC.d(TAG, "$folderName: eTag changed") + result?.data?.get(0) as? String + } + + else -> if (connectivityService.isInternetWalled) { + Log_OC.d(TAG, "No connectivity, skipping sync") + null + } else { + Log_OC.d(TAG, "$folderName: eTag changed") + result?.data?.get(0) as? String + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/TestJob.kt b/app/src/main/java/com/nextcloud/client/jobs/TestJob.kt new file mode 100644 index 000000000000..2d700420925b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/TestJob.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs + +import android.content.Context +import androidx.work.Data +import androidx.work.Worker +import androidx.work.WorkerParameters + +class TestJob(appContext: Context, params: WorkerParameters, private val backgroundJobManager: BackgroundJobManager) : + Worker(appContext, params) { + + companion object { + private const val MAX_PROGRESS = 100 + private const val DELAY_MS = 1000L + private const val PROGRESS_KEY = "progress" + } + + override fun doWork(): Result { + backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class)) + + for (i in 0..MAX_PROGRESS) { + Thread.sleep(DELAY_MS) + val progress = Data.Builder() + .putInt(PROGRESS_KEY, i) + .build() + setProgressAsync(progress) + } + + val result = Result.success() + backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result) + return result + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadEntityResult.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadEntityResult.kt new file mode 100644 index 000000000000..bd6b8a208d5c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadEntityResult.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import com.nextcloud.client.database.entity.UploadEntity +import com.owncloud.android.db.OCUpload + +sealed class AutoUploadEntityResult { + data object NonRetryable : AutoUploadEntityResult() + data object CreationError : AutoUploadEntityResult() + data object Uploaded : AutoUploadEntityResult() + data class Success(val data: Pair) : AutoUploadEntityResult() +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadHelper.kt new file mode 100644 index 000000000000..93f8b262add6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadHelper.kt @@ -0,0 +1,171 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import android.provider.MediaStore +import androidx.core.net.toUri +import com.nextcloud.utils.extensions.toLocalPath +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.lib.common.utils.Log_OC +import java.io.IOException +import java.nio.file.AccessDeniedException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +@Suppress("TooGenericExceptionCaught", "MagicNumber", "ReturnCount") +class AutoUploadHelper(private val repository: FileSystemRepository) { + companion object { + private const val TAG = "AutoUploadHelper" + private const val MAX_DEPTH = 100 + } + + fun insertEntries(folder: SyncedFolder) { + when (folder.type) { + MediaFolderType.IMAGE -> { + repository.insertFromUri(MediaStore.Images.Media.INTERNAL_CONTENT_URI, folder) + repository.insertFromUri(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, folder) + } + + MediaFolderType.VIDEO -> { + repository.insertFromUri(MediaStore.Video.Media.INTERNAL_CONTENT_URI, folder) + repository.insertFromUri(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, folder) + } + + else -> { + insertCustomFolderIntoDB(folder) + } + } + } + + /** + * Attempts to get the file path from a content URI string (e.g., content://media/external/images/media/2281) + * and checks its type. If the conditions are met, the file is stored for auto-upload. + *

+ * If any attempt fails, the method returns {@code false}. + * + * @param syncedFolder The folder marked for auto-upload. + * @param contentUris An array of content URI strings collected from + * {@link ContentObserverWork##checkAndTriggerAutoUpload()}. + * @return {@code true} if all changed content URIs were successfully stored; {@code false} otherwise. + */ + fun insertChangedEntries(syncedFolder: SyncedFolder, contentUris: Array?): Boolean { + contentUris?.forEach { uriString -> + try { + val uri = uriString.toUri() + repository.insertFromUri(uri, syncedFolder, true) + } catch (e: Exception) { + Log_OC.e(TAG, "Invalid URI: $uriString", e) + return false + } + } + + Log_OC.d(TAG, "Changed content URIs successfully stored") + + return true + } + + fun insertCustomFolderIntoDB(folder: SyncedFolder): Int { + val path = Paths.get(folder.localPath) + + if (!Files.exists(path)) { + Log_OC.w(TAG, "Folder does not exist: ${folder.localPath}") + return 0 + } + + if (!Files.isReadable(path)) { + Log_OC.w(TAG, "Folder is not readable: ${folder.localPath}") + return 0 + } + + val excludeHidden = folder.isExcludeHidden + + var fileCount = 0 + var skipCount = 0 + var errorCount = 0 + + try { + Files.walkFileTree( + path, + setOf(FileVisitOption.FOLLOW_LINKS), + MAX_DEPTH, + object : SimpleFileVisitor() { + + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes?): FileVisitResult { + if (excludeHidden && dir != path && dir.toFile().isHidden) { + Log_OC.d(TAG, "Skipping hidden directory: ${dir.fileName}") + skipCount++ + return FileVisitResult.SKIP_SUBTREE + } + + return FileVisitResult.CONTINUE + } + + override fun visitFile(file: Path, attrs: BasicFileAttributes?): FileVisitResult { + try { + val javaFile = file.toFile() + val lastModified = attrs?.lastModifiedTime()?.toMillis() ?: javaFile.lastModified() + val creationTime = attrs?.creationTime()?.toMillis() + val localPath = file.toLocalPath() + + repository.insertOrReplace(localPath, lastModified, creationTime, folder) + + fileCount++ + + if (fileCount % 100 == 0) { + Log_OC.d(TAG, "Processed $fileCount files so far...") + } + } catch (e: Exception) { + Log_OC.e(TAG, "Error processing file: $file", e) + errorCount++ + } + + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException?): FileVisitResult { + when (exc) { + is AccessDeniedException -> { + Log_OC.w(TAG, "Access denied: $file") + } + + else -> { + Log_OC.e(TAG, "Failed to visit file: $file", exc) + } + } + errorCount++ + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) { + Log_OC.e(TAG, "Error after visiting directory: $dir", exc) + errorCount++ + } + return FileVisitResult.CONTINUE + } + } + ) + + Log_OC.d( + TAG, + "Scan complete for ${folder.localPath}: " + + "$fileCount files processed, $skipCount skipped, $errorCount errors" + ) + } catch (e: Exception) { + Log_OC.e(TAG, "Error walking file tree: ${folder.localPath}", e) + } + + return fileCount + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadNotificationManager.kt new file mode 100644 index 000000000000..3b4532ecbfa4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadNotificationManager.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import android.content.Context +import com.nextcloud.client.jobs.notification.WorkerNotificationManager +import com.owncloud.android.R +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils + +class AutoUploadNotificationManager(context: Context, viewThemeUtils: ViewThemeUtils, id: Int) : + WorkerNotificationManager( + id, + context, + viewThemeUtils, + tickerId = R.string.foreground_service_upload, + channelId = NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD + ) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt new file mode 100644 index 000000000000..84902f10becb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -0,0 +1,451 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import android.app.Notification +import android.content.Context +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.database.entity.toOCUpload +import com.nextcloud.client.database.entity.toUploadEntity +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.utils.extensions.getLog +import com.nextcloud.utils.extensions.isConflict +import com.nextcloud.utils.extensions.isNonRetryable +import com.nextcloud.utils.extensions.updateStatus +import com.owncloud.android.R +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.activity.SettingsActivity +import com.owncloud.android.utils.theme.CapabilityUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.cancellation.CancellationException + +@Suppress("LongParameterList", "TooManyFunctions", "TooGenericExceptionCaught") +class AutoUploadWorker( + private val context: Context, + params: WorkerParameters, + private val userAccountManager: UserAccountManager, + private val uploadsStorageManager: UploadsStorageManager, + private val connectivityService: ConnectivityService, + private val powerManagementService: PowerManagementService, + private val syncedFolderProvider: SyncedFolderProvider, + private val repository: FileSystemRepository, + val viewThemeUtils: ViewThemeUtils, + localBroadcastManager: LocalBroadcastManager, + private val autoUploadHelper: AutoUploadHelper +) : CoroutineWorker(context, params) { + + companion object { + const val TAG = "🔄📤" + "AutoUpload" + const val OVERRIDE_POWER_SAVING = "overridePowerSaving" + const val SYNCED_FOLDER_ID = "syncedFolderId" + const val NOTIFICATION_ID = 266 + } + + private val syncFolderHelper = SyncFolderHelper(context) + private val fileUploadEventBroadcaster = FileUploadEventBroadcaster(localBroadcastManager) + private lateinit var syncedFolder: SyncedFolder + private val notificationManager = AutoUploadNotificationManager(context, viewThemeUtils, NOTIFICATION_ID) + private val fileUploadHelper = FileUploadHelper.instance() + + @Suppress("ReturnCount") + override suspend fun doWork(): Result { + return try { + trySetForeground() + + val syncFolderId = inputData.getLong(SYNCED_FOLDER_ID, -1) + syncedFolder = syncedFolderProvider.getSyncedFolderByID(syncFolderId) + ?.takeIf { it.isEnabled } ?: return Result.failure() + + Log_OC.d(TAG, syncedFolder.getLog()) + + if (canExitEarly(syncFolderId)) { + return Result.success() + } + + if (powerManagementService.isPowerSavingEnabled) { + Log_OC.w(TAG, "power saving mode enabled") + } + + // insert entries based on selected local storage path + autoUploadHelper.insertEntries(syncedFolder) + uploadFiles(syncedFolder) + + // only update last scan time after uploading files + syncedFolder.lastScanTimestampMs = System.currentTimeMillis() + syncedFolderProvider.updateSyncFolder(syncedFolder) + + Log_OC.d(TAG, "✅ ${syncedFolder.remotePath} completed") + Result.success() + } catch (e: CancellationException) { + Log_OC.w(TAG, "⚠️ Job cancelled") + throw e + } catch (e: Exception) { + Log_OC.e(TAG, "❌ failed: ${e.message}") + Result.failure() + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = createNotification(context.getString(R.string.upload_files)) + return notificationManager.getForegroundInfo(notification) + } + + private suspend fun updateNotification() { + getStartNotificationTitle()?.let { (localFolderName, remoteFolderName) -> + try { + val startNotification = createNotification( + context.getString( + R.string.auto_upload_worker_start_text, + localFolderName, + remoteFolderName + ) + ) + + setForeground(notificationManager.getForegroundInfo(startNotification)) + } catch (e: Exception) { + Log_OC.w(TAG, "⚠️ Could not update notification: ${e.message}") + } + } + } + + private suspend fun trySetForeground() { + try { + val notification = createNotification(context.getString(R.string.upload_files)) + setForeground(notificationManager.getForegroundInfo(notification)) + } catch (e: Exception) { + Log_OC.w(TAG, "⚠️ Could not set foreground service: ${e.message}") + } + } + + private fun createNotification(title: String): Notification = notificationManager.notificationBuilder + .setContentTitle(title) + .setSmallIcon(R.drawable.uploads) + .setOngoing(true) + .setSound(null) + .setVibrate(null) + .setOnlyAlertOnce(true) + .setSilent(true) + .build() + + @Suppress("TooGenericExceptionCaught") + private fun getStartNotificationTitle(): Pair? = try { + val localPath = syncedFolder.localPath + val remotePath = syncedFolder.remotePath + if (localPath.isBlank() || remotePath.isBlank()) { + null + } else { + try { + File(localPath).name to File(remotePath).name + } catch (_: Exception) { + null + } + } + } catch (_: Exception) { + null + } + + @Suppress("ReturnCount") + private suspend fun canExitEarly(syncedFolderID: Long): Boolean { + val overridePowerSaving = inputData.getBoolean(OVERRIDE_POWER_SAVING, false) + if ((powerManagementService.isPowerSavingEnabled && !overridePowerSaving)) { + Log_OC.w(TAG, "⚡ Skipping: device is in power saving mode") + return true + } + + if (syncedFolderID < 0) { + Log_OC.e(TAG, "invalid sync folder id") + return true + } + + val hasPendingFiles = repository.hasPendingFiles(syncedFolder) + if (hasPendingFiles) { + Log_OC.d(TAG, "pending files found, starting...") + return false + } + + val totalScanInterval = syncedFolder.getTotalScanInterval(connectivityService, powerManagementService) + val currentTime = System.currentTimeMillis() + val passedScanInterval = totalScanInterval <= currentTime + + if (!passedScanInterval && !overridePowerSaving) { + Log_OC.w( + TAG, + "skipped since started before scan interval and nothing todo: " + syncedFolder.localPath + ) + return true + } + + Log_OC.d(TAG, "starting ...") + + return false + } + + private fun getUserOrReturn(syncedFolder: SyncedFolder): User? { + val optionalUser = userAccountManager.getUser(syncedFolder.account) + if (!optionalUser.isPresent) { + Log_OC.w(TAG, "user not present") + return null + } + return optionalUser.get() + } + + @Suppress("DEPRECATION") + private fun getUploadSettings(syncedFolder: SyncedFolder): Triple { + val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light) + val accountName = syncedFolder.account + + return if (lightVersion) { + Log_OC.d(TAG, "light version is used") + val arbitraryDataProvider = ArbitraryDataProviderImpl(context) + val needsCharging = context.resources.getBoolean(R.bool.syncedFolder_light_on_charging) + val needsWifi = arbitraryDataProvider.getBooleanValue( + accountName, + SettingsActivity.SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI + ) + val uploadActionString = context.resources.getString(R.string.syncedFolder_light_upload_behaviour) + val uploadAction = FileUploadWorker.getUploadAction(uploadActionString) + Log_OC.d(TAG, "upload action is: $uploadAction") + Triple(needsCharging, needsWifi, uploadAction) + } else { + Log_OC.d(TAG, "not light version is used") + Triple(syncedFolder.isChargingOnly, syncedFolder.isWifiOnly, syncedFolder.uploadAction) + } + } + + @Suppress("LongMethod", "DEPRECATION", "TooGenericExceptionCaught") + private suspend fun uploadFiles(syncedFolder: SyncedFolder) = withContext(Dispatchers.IO) { + val user = getUserOrReturn(syncedFolder) ?: return@withContext + val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(ocAccount, context) + val capability = CapabilityUtils.getCapability(user, context) + + updateNotification() + + var lastId = 0 + + while (isActive) { + val filePathsWithIds = repository.getFilePathsWithIds(syncedFolder, lastId) + + if (filePathsWithIds.isEmpty()) { + Log_OC.w(TAG, "no more files to upload at lastId: $lastId") + break + } + Log_OC.d(TAG, "started, processing batch: lastId=$lastId, count=${filePathsWithIds.size}") + + filePathsWithIds.forEachIndexed { batchIndex, (path, id) -> + ensureActive() + + val file = File(path) + val localPath = file.absolutePath + val remotePath = syncFolderHelper.getAutoUploadRemotePath(syncedFolder, file) + + try { + val entityResult = getEntityResult(user, localPath, remotePath, capability) + if (entityResult !is AutoUploadEntityResult.Success) { + repository.markFileAsHandled(localPath, syncedFolder) + Log_OC.d(TAG, "marked file as handled: $localPath") + continue + } + + var (uploadEntity, upload) = entityResult.data + + // if local file deleted, upload process cannot be started or retriable thus needs to be removed + if (path.isEmpty() || !file.exists()) { + Log_OC.w(TAG, "detected non-existing local file, removing entity") + deleteNonExistingFile(path, id, upload) + continue + } + + try { + // Insert/update to IN_PROGRESS state before starting upload + val generatedId = uploadsStorageManager.uploadDao.insertOrReplace(uploadEntity) + repository.updateRemotePath(upload, syncedFolder) + uploadEntity = uploadEntity.copy(id = generatedId.toInt()) + upload.uploadId = generatedId + + fileUploadEventBroadcaster.sendUploadEnqueued(context) + val operation = createUploadFileOperation(upload, user) + Log_OC.d(TAG, "🕒 uploading: $localPath, id: $generatedId") + + val result = operation.execute(client) + fileUploadEventBroadcaster.sendUploadStarted(operation, context) + + UploadErrorNotificationManager.handleResult( + context, + notificationManager, + operation, + result + ) + + if (result.isSuccess) { + repository.markFileAsHandled(localPath, syncedFolder) + Log_OC.d(TAG, "✅ upload completed: $localPath") + } else { + Log_OC.e( + TAG, + "❌ upload failed $localPath (${upload.accountName}): ${result.logMessage}" + ) + + // Mark CONFLICT files as handled to prevent retries + if (result.code.isConflict()) { + repository.markFileAsHandled(localPath, syncedFolder) + Log_OC.w(TAG, "Marked CONFLICT file as handled: $localPath") + } + } + + val isLastInBatch = (batchIndex == filePathsWithIds.size - 1) + if (isLastInBatch) { + sendUploadFinishEvent(operation, result) + } + } catch (e: Exception) { + uploadsStorageManager.updateStatus( + uploadEntity, + UploadsStorageManager.UploadStatus.UPLOAD_FAILED + ) + Log_OC.e( + TAG, + "Exception during upload file, localPath: $localPath, remotePath: $remotePath," + + " exception: $e" + ) + + if (path.isEmpty() || !file.exists()) { + Log_OC.w(TAG, "detected non-existing local file, removing entity") + deleteNonExistingFile(path, id, upload) + continue + } + } + } catch (e: Exception) { + Log_OC.e( + TAG, + "Exception uploadFiles during creating entity and upload, localPath: $localPath, " + + "remotePath: $remotePath, exception: $e" + ) + } finally { + // update last id so upload can continue where it left + lastId = id + } + } + } + } + + private suspend fun deleteNonExistingFile(path: String, id: Int, upload: OCUpload) { + repository.deleteByLocalPathAndId(path, id) + uploadsStorageManager.removeUpload(upload) + } + + @Suppress("ReturnCount") + private fun getEntityResult( + user: User, + localPath: String, + remotePath: String, + capability: OCCapability + ): AutoUploadEntityResult { + val (needsCharging, needsWifi, uploadAction) = getUploadSettings(syncedFolder) + Log_OC.d(TAG, "creating oc upload for ${user.accountName}") + + // Get existing upload or create new one + val uploadEntity = fileUploadHelper.getUploadByPaths( + localPath = localPath, + remotePath = remotePath, + accountName = user.accountName + ) + + val lastUploadResult = uploadEntity?.lastResult?.let { UploadResult.fromValue(it) } + if (lastUploadResult?.isNonRetryable() == true) { + Log_OC.w( + TAG, + "last upload failed with ${lastUploadResult.value}, skipping auto-upload: $localPath" + ) + return AutoUploadEntityResult.NonRetryable + } + + val upload = try { + uploadEntity?.toOCUpload(capability) ?: OCUpload(localPath, remotePath, user.accountName) + } catch (_: IllegalArgumentException) { + Log_OC.e(TAG, "cannot construct oc upload") + return AutoUploadEntityResult.CreationError + } + + // only valid for skip collision policy other scenarios will be handled in UploadFileOperation.java + if (upload.lastResult == UploadResult.UPLOADED && + syncedFolder.nameCollisionPolicy == NameCollisionPolicy.SKIP + ) { + Log_OC.d(TAG, "no need to create and process this entity file is already uploaded") + return AutoUploadEntityResult.Uploaded + } + + upload.apply { + uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS + nameCollisionPolicy = syncedFolder.nameCollisionPolicy + isUseWifiOnly = needsWifi + isWhileChargingOnly = needsCharging + localAction = uploadAction + + // Only set these for new uploads + if (uploadEntity == null) { + createdBy = UploadFileOperation.CREATED_AS_INSTANT_PICTURE + isCreateRemoteFolder = true + } + } + + return AutoUploadEntityResult.Success(upload.toUploadEntity() to upload) + } + + private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation( + uploadsStorageManager, + connectivityService, + powerManagementService, + user, + null, + upload, + upload.nameCollisionPolicy, + upload.localAction, + context, + upload.isUseWifiOnly, + upload.isWhileChargingOnly, + true, + FileDataStorageManager(user, context.contentResolver) + ) + + private fun sendUploadFinishEvent(operation: UploadFileOperation, result: RemoteOperationResult<*>) { + fileUploadEventBroadcaster.sendUploadCompleted( + operation, + result, + context + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt new file mode 100644 index 000000000000..6ca9e37629fc --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt @@ -0,0 +1,248 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import com.nextcloud.client.database.dao.FileSystemDao +import com.nextcloud.client.database.entity.FilesystemEntity +import com.nextcloud.utils.extensions.shouldSkipFile +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.SyncedFolderUtils +import java.io.File +import java.util.zip.CRC32 + +@Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "MagicNumber", "ReturnCount") +class FileSystemRepository( + private val dao: FileSystemDao, + private val uploadsStorageManager: UploadsStorageManager, + private val context: Context +) { + private val syncFolderHelper = SyncFolderHelper(context) + + companion object { + private const val TAG = "FilesystemRepository" + const val BATCH_SIZE = 50 + } + + suspend fun isBelongToAnyAutoFolder(localPath: String): Boolean = dao.isBelongToAnyAutoFolder(localPath) + + fun deleteAutoUploadAndUploadEntity(syncedFolder: SyncedFolder, localPath: String, entity: FilesystemEntity) { + Log_OC.d(TAG, "deleting auto upload entity and upload entity") + + val file = File(localPath) + val remotePath = syncFolderHelper.getAutoUploadRemotePath(syncedFolder, file) + uploadsStorageManager.uploadDao.deleteByRemotePathAndAccountName( + remotePath = remotePath, + accountName = syncedFolder.account + ) + dao.delete(entity) + } + + suspend fun hasPendingFiles(syncedFolder: SyncedFolder): Boolean = dao.hasPendingFiles(syncedFolder.id.toString()) + + suspend fun deleteByLocalPathAndId(path: String, id: Int) { + dao.deleteByLocalPathAndId(path, id) + } + + suspend fun getFilePathsWithIds(syncedFolder: SyncedFolder, lastId: Int): List> { + val syncedFolderId = syncedFolder.id.toString() + Log_OC.d(TAG, "Fetching candidate files for syncedFolderId = $syncedFolderId") + + val entities = dao.getAutoUploadFilesEntities(syncedFolderId, BATCH_SIZE, lastId) + val filtered = mutableListOf>() + + for (entity in entities) { + val file = SyncedFolderUtils.validateForAutoUpload(entity.localPath) + if (file == null) { + deleteAutoUploadAndUploadEntity(syncedFolder, entity.localPath ?: "", entity) + continue + } + + Log_OC.d(TAG, "Adding path to upload: ${entity.localPath}") + + if (entity.id != null) { + filtered.add(entity.localPath!! to entity.id) + } else { + Log_OC.w(TAG, "cant adding path to upload, id is null") + } + } + + return filtered + } + + suspend fun updateRemotePath(upload: OCUpload, syncedFolder: SyncedFolder) { + val syncedFolderIdStr = syncedFolder.id.toString() + + try { + dao.updateRemotePath(remotePath = upload.remotePath, localPath = upload.localPath, syncedFolderIdStr) + Log_OC.d( + TAG, + "file system entity remote path updated. remotePath: ${upload.remotePath}, localPath: " + + "${upload.localPath} for syncedFolderId=$syncedFolderIdStr" + ) + } catch (e: Exception) { + Log_OC.e(TAG, "updateRemotePath(): ${e.message}", e) + } + } + + suspend fun markFileAsHandled(localPath: String, syncedFolder: SyncedFolder) { + val syncedFolderIdStr = syncedFolder.id.toString() + + try { + dao.markFileAsUploaded(localPath, syncedFolderIdStr) + Log_OC.d(TAG, "Marked file as uploaded: $localPath for syncedFolderId=$syncedFolderIdStr") + } catch (e: Exception) { + Log_OC.e(TAG, "markFileAsHandled(): ${e.message}", e) + } + } + + @JvmOverloads + fun insertFromUri(uri: Uri, syncedFolder: SyncedFolder, checkFileType: Boolean = false) { + val projection = arrayOf( + MediaStore.MediaColumns.DATA, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.DATE_ADDED + ) + + var syncedPath = syncedFolder.localPath + if (syncedPath.isNullOrEmpty()) { + Log_OC.w(TAG, "Synced folder path is null or empty") + return + } + + if (!syncedPath.endsWith(File.separator)) { + syncedPath += File.separator + } + + val selection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.IS_PENDING} = 0" + } else { + "${MediaStore.MediaColumns.DATA} LIKE ?" + } + val selectionArgs = arrayOf("$syncedPath%") + + Log_OC.d(TAG, "Querying MediaStore for files in: $syncedPath, uri: $uri") + + val cursor = context.contentResolver.query( + uri, + projection, + selection, + selectionArgs, + null + ) + + cursor?.use { + val idxData = cursor.getColumnIndex(MediaStore.MediaColumns.DATA) + val idxModified = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED) + val idxAdded = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED) + + if (idxData == -1) { + Log_OC.e(TAG, "MediaStore column DATA missing — cannot process URI: $uri") + return + } + + while (cursor.moveToNext()) { + val filePath = cursor.getString(idxData) + + val lastModifiedMs = if (idxModified != -1) { + cursor.getLong(idxModified) * 1000 + } else { + null + } + + val creationTimeMs = if (idxAdded != -1) { + cursor.getLong(idxAdded) * 1000 + } else { + null + } + + Log_OC.d( + TAG, + "Found file: $filePath (created=$creationTimeMs, modified=$lastModifiedMs)" + ) + + insertOrReplace(filePath, lastModifiedMs, creationTimeMs, syncedFolder, checkFileType) + } + } + } + + fun insertOrReplace( + localPath: String?, + lastModified: Long?, + creationTime: Long?, + syncedFolder: SyncedFolder, + checkFileType: Boolean = false + ) { + try { + val file = SyncedFolderUtils.validateForAutoUpload(localPath) + if (file == null) { + Log_OC.w(TAG, "file null, cannot insert or replace: $localPath") + return + } + + if (checkFileType && !syncedFolder.isFileInFolderWithCorrectMediaType(file, localPath)) { + Log_OC.w(TAG, "synced folder not contains typed file: $localPath") + return + } + + val entity = dao.getFileByPathAndFolder(localPath!!, syncedFolder.id.toString()) + + val fileModified = (lastModified ?: file.lastModified()) + val hasNotChanged = entity?.fileModified == fileModified + val fileSentForUpload = entity?.fileSentForUpload == 1 + + if (hasNotChanged && fileSentForUpload) { + Log_OC.d(TAG, "File hasn't changed since last scan. skipping: $localPath") + return + } + + if (syncedFolder.shouldSkipFile(file, fileModified, creationTime, fileSentForUpload)) { + return + } + + val crc = getFileChecksum(file) + val newEntity = FilesystemEntity( + id = entity?.id, + localPath = localPath, + remotePath = null, // will be updated later + fileIsFolder = if (file.isDirectory) 1 else 0, + fileFoundRecently = System.currentTimeMillis(), + fileSentForUpload = 0, // Reset to 0 to queue for upload + syncedFolderId = syncedFolder.id.toString(), + crc32 = crc?.toString(), + fileModified = fileModified + ) + + Log_OC.d(TAG, "inserting new file system entity: $newEntity") + + dao.insertOrReplace(newEntity) + } catch (e: Exception) { + Log_OC.e(TAG, "Failed to insert/update file: $localPath", e) + } + } + + private fun getFileChecksum(file: File): Long? = try { + file.inputStream().use { fis -> + val crc = CRC32() + val buffer = ByteArray(64 * 1024) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } > 0) { + crc.update(buffer, 0, bytesRead) + } + crc.value + } + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/SyncFolderHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/SyncFolderHelper.kt new file mode 100644 index 000000000000..bff89554fb55 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/SyncFolderHelper.kt @@ -0,0 +1,111 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import android.content.Context +import androidx.exifinterface.media.ExifInterface +import com.nextcloud.client.preferences.SubFolderRule +import com.nextcloud.utils.autoRename.AutoRename +import com.owncloud.android.R +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeType +import com.owncloud.android.utils.theme.CapabilityUtils +import java.io.File +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.TimeZone + +class SyncFolderHelper(private val context: Context) { + + companion object { + private const val TAG = "SyncFolderHelper" + } + + fun getAutoUploadRemotePath(syncedFolder: SyncedFolder, file: File): String { + val resources = context.resources + val isLightVersion = resources.getBoolean(R.bool.syncedFolder_light) + val lastModificationTime = calculateLastModificationTime(file, syncedFolder) + + val remoteFolder: String + val useSubfolders: Boolean + val subFolderRule: SubFolderRule + + if (isLightVersion) { + remoteFolder = resources.getString(R.string.syncedFolder_remote_folder) + useSubfolders = resources.getBoolean(R.bool.syncedFolder_light_use_subfolders) + subFolderRule = SubFolderRule.YEAR_MONTH + } else { + remoteFolder = syncedFolder.remotePath + useSubfolders = syncedFolder.isSubfolderByDate + subFolderRule = syncedFolder.subfolderRule + } + + var result = FileStorageUtils.getInstantUploadFilePath( + file, + resources.configuration.locales[0], + remoteFolder, + syncedFolder.localPath, + lastModificationTime, + useSubfolders, + subFolderRule + ) + + val capability = CapabilityUtils.getCapability(context) + result = AutoRename.rename(result, capability) + + Log_OC.d(TAG, "auto upload remote path: $result") + + return result + } + + @Suppress("NestedBlockDepth") + private fun calculateLastModificationTime(file: File, syncedFolder: SyncedFolder): Long { + val resources = context.resources + val currentLocale = resources.configuration.locales[0] + val formatter = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", currentLocale).apply { + timeZone = TimeZone.getTimeZone(TimeZone.getDefault().id) + } + var lastModificationTime = file.lastModified() + if (MediaFolderType.IMAGE == syncedFolder.type && hasExif(file)) { + Log_OC.d(TAG, "calculateLastModificationTime exif found") + + @Suppress("TooGenericExceptionCaught") + try { + val exifInterface = ExifInterface(file.absolutePath) + val exifDate = exifInterface.getAttribute(ExifInterface.TAG_DATETIME) + if (!exifDate.isNullOrBlank()) { + val pos = ParsePosition(0) + val dateTime = formatter.parse(exifDate, pos) + if (dateTime != null) { + lastModificationTime = dateTime.time + Log_OC.w( + TAG, + "calculateLastModificationTime calculatedTime is: $lastModificationTime" + ) + } else { + Log_OC.w(TAG, "calculateLastModificationTime dateTime is empty") + } + } else { + Log_OC.w(TAG, "calculateLastModificationTime exifDate is empty") + } + } catch (e: Exception) { + Log_OC.d(TAG, "Failed to get the proper time " + e.localizedMessage) + } + } + return lastModificationTime + } + + private fun hasExif(file: File): Boolean { + val mimeType = FileStorageUtils.getMimeTypeFromName(file.absolutePath) + return mimeType.equals(MimeType.JPEG, ignoreCase = true) || + mimeType.equals(MimeType.TIFF, ignoreCase = true) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/clipboard/ClipboardClearWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/clipboard/ClipboardClearWorker.kt new file mode 100644 index 000000000000..a32ee3c3a035 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/clipboard/ClipboardClearWorker.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.clipboard + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.owncloud.android.lib.common.utils.Log_OC + +class ClipboardClearWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { + private val tag = ClipboardClearWorker::class.java.name + + companion object { + const val CLIPBOARD_TEXT = "clipboard_text" + } + + @Suppress("TooGenericExceptionCaught", "ReturnCount") + override fun doWork(): Result { + try { + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val currentClip = clipboardManager.primaryClip ?: return Result.success() + val clipboardText = currentClip.getItemAt(0).text?.toString() ?: return Result.success() + val copiedText = inputData.getString(CLIPBOARD_TEXT) + if (copiedText != clipboardText) { + return Result.success() + } + + clipboardManager.clearPrimaryClip() + + return Result.success() + } catch (e: Exception) { + Log_OC.e(tag, "Error in clipboard clear worker", e) + return Result.retry() + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/DownloadNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/download/DownloadNotificationManager.kt new file mode 100644 index 000000000000..9ccb36b67237 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/download/DownloadNotificationManager.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.download + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import com.nextcloud.client.jobs.notification.WorkerNotificationManager +import com.nextcloud.utils.numberFormatter.NumberFormatter +import com.owncloud.android.R +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.io.File +import java.security.SecureRandom + +@Suppress("TooManyFunctions") +class DownloadNotificationManager(id: Int, private val context: Context, viewThemeUtils: ViewThemeUtils) : + WorkerNotificationManager( + id, + context, + viewThemeUtils, + tickerId = R.string.downloader_download_in_progress_ticker, + channelId = NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD + ) { + + private var lastPercent = -1 + + @Suppress("MagicNumber") + fun prepareForStart(operation: DownloadFileOperation) { + currentOperationTitle = File(operation.savePath).name + + notificationBuilder.run { + setContentTitle(currentOperationTitle) + setOngoing(false) + setProgress(100, 0, operation.size < 0) + } + + showNotification() + } + + fun prepareForResult() { + notificationBuilder + .setAutoCancel(true) + .setOngoing(false) + .setProgress(0, 0, false) + } + + @Suppress("MagicNumber") + fun updateDownloadProgress(percent: Int, totalToTransfer: Long) { + // If downloads are so fast, no need to notify again. + if (percent == lastPercent) { + return + } + lastPercent = percent + + val progressText = NumberFormatter.getPercentageText(percent) + setProgress(percent, progressText, totalToTransfer < 0) + showNotification() + } + + @Suppress("MagicNumber") + fun dismissNotification() { + dismissNotification(2000) + } + + fun showNewNotification(text: String) { + val notifyId = SecureRandom().nextInt() + + notificationBuilder.run { + setProgress(0, 0, false) + setContentTitle(text) + setOngoing(false) + notificationManager.notify(notifyId, this.build()) + } + } + + fun setContentIntent(intent: Intent, flag: Int) { + notificationBuilder.setContentIntent( + PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + intent, + flag + ) + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/DownloadTask.kt b/app/src/main/java/com/nextcloud/client/jobs/download/DownloadTask.kt new file mode 100644 index 000000000000..b8708dfd5b26 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/download/DownloadTask.kt @@ -0,0 +1,94 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.download + +import android.content.ContentResolver +import android.content.Context +import com.nextcloud.client.core.IsCancelled +import com.nextcloud.client.files.DownloadRequest +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.utils.MimeTypeUtil +import java.io.File + +/** + * This runnable object encapsulates file download logic. It has been extracted to wrap + * network operation and storage manager interactions, as those pose testing challenges + * that cannot be addressed due to large number of dependencies. + * + * This design can be regarded as intermediary refactoring step. + */ +class DownloadTask( + private val context: Context, + private val contentResolver: ContentResolver, + private val clientProvider: () -> OwnCloudClient +) { + + data class Result(val file: OCFile, val success: Boolean) + + /** + * This class is a helper factory to to keep static dependencies + * injection out of the downloader instance. + * + * @param context Context + * @param clientProvider Provide client - this must be called on background thread + * @param contentResolver content resovler used to access file storage + */ + class Factory( + private val context: Context, + private val clientProvider: () -> OwnCloudClient, + private val contentResolver: ContentResolver + ) { + fun create(): DownloadTask = DownloadTask(context, contentResolver, clientProvider) + } + + // Unused progress, isCancelled arguments needed for TransferManagerTest + fun download(request: DownloadRequest, progress: (Int) -> Unit, isCancelled: IsCancelled): Result { + val op = DownloadFileOperation(request.user, request.file, context) + val client = clientProvider.invoke() + val result = op.execute(client) + + return if (result.isSuccess) { + val storageManager = FileDataStorageManager( + request.user, + contentResolver + ) + val file = saveDownloadedFile(op, storageManager) + Result(file, true) + } else { + Result(request.file, false) + } + } + + private fun saveDownloadedFile(op: DownloadFileOperation, storageManager: FileDataStorageManager): OCFile { + val file = storageManager.getFileById(op.file.fileId) as OCFile + + file.apply { + val syncDate = System.currentTimeMillis() + lastSyncDateForProperties = syncDate + lastSyncDateForData = syncDate + isUpdateThumbnailNeeded = true + modificationTimestamp = op.modificationTimestamp + modificationTimestampAtLastSyncForData = op.modificationTimestamp + etag = op.etag + mimeType = op.mimeType + storagePath = op.savePath + fileLength = File(op.savePath).length() + remoteId = op.file.remoteId + } + + storageManager.saveFile(file) + + if (MimeTypeUtil.isMedia(op.mimeType)) { + FileDataStorageManager.triggerMediaScan(file.storagePath) + } + + return file + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadError.kt b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadError.kt new file mode 100644 index 000000000000..cb027e9ae0ad --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadError.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.download + +enum class FileDownloadError { + Failed, + Cancelled +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadEventBroadcaster.kt b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadEventBroadcaster.kt new file mode 100644 index 000000000000..17de7e7f9802 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadEventBroadcaster.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.download + +import android.content.Context +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadFileOperation + +class FileDownloadEventBroadcaster(private val context: Context, private val broadcastManager: LocalBroadcastManager) { + companion object { + private const val TAG = "📣" + "FileDownloadBroadcastManager" + + private const val PREFIX = "com.nextcloud.client.download." + + const val ACTION_DOWNLOAD_ENQUEUED = PREFIX + "ACTION_DOWNLOAD_ENQUEUED" + const val ACTION_DOWNLOAD_COMPLETED = PREFIX + "ACTION_DOWNLOAD_COMPLETED" + + const val EXTRA_DOWNLOAD_RESULT = PREFIX + "EXTRA_DOWNLOAD_RESULT" + const val EXTRA_REMOTE_PATH = PREFIX + "EXTRA_REMOTE_PATH" + const val EXTRA_ACCOUNT_NAME = PREFIX + "EXTRA_ACCOUNT_NAME" + const val EXTRA_CURRENT_DOWNLOAD_ACCOUNT_NAME = PREFIX + "EXTRA_CURRENT_DOWNLOAD_ACCOUNT_NAME" + const val EXTRA_CURRENT_DOWNLOAD_FILE_ID = PREFIX + "EXTRA_CURRENT_DOWNLOAD_FILE_ID" + const val EXTRA_PACKAGE_NAME = PREFIX + "PACKAGE_NAME" + const val EXTRA_ACTIVITY_NAME = PREFIX + "ACTIVITY_NAME" + const val EXTRA_DOWNLOAD_BEHAVIOUR = PREFIX + "DOWNLOAD_BEHAVIOUR" + } + + fun sendDownloadEnqueued( + accountName: String, + remotePath: String, + packageName: String, + fileId: Long?, + currentDownloadAccountName: String? + ) { + Log_OC.d(TAG, "Download enqueued broadcast sent") + + val intent = Intent(ACTION_DOWNLOAD_ENQUEUED).apply { + putExtra(EXTRA_ACCOUNT_NAME, accountName) + putExtra(EXTRA_REMOTE_PATH, remotePath) + + fileId?.let { + putExtra(EXTRA_CURRENT_DOWNLOAD_FILE_ID, fileId) + } + + currentDownloadAccountName?.let { + putExtra(EXTRA_CURRENT_DOWNLOAD_ACCOUNT_NAME, currentDownloadAccountName) + } + setPackage(packageName) + } + + broadcastManager.sendBroadcast(intent) + } + + fun sendDownloadCompleted(download: DownloadFileOperation, downloadResult: RemoteOperationResult<*>) { + Log_OC.d(TAG, "Download completed broadcast sent") + + val intent = Intent(ACTION_DOWNLOAD_COMPLETED).apply { + putExtra(EXTRA_DOWNLOAD_RESULT, downloadResult.isSuccess) + putExtra(EXTRA_ACCOUNT_NAME, download.user.accountName) + putExtra(EXTRA_REMOTE_PATH, download.remotePath) + putExtra(EXTRA_DOWNLOAD_BEHAVIOUR, download.behaviour) + putExtra(EXTRA_ACTIVITY_NAME, download.activityName) + putExtra(EXTRA_PACKAGE_NAME, download.packageName) + setPackage(context.packageName) + } + + broadcastManager.sendBroadcast(intent) + } + + fun sendDownloadCompleted(accountName: String, remotePath: String?, packageName: String, success: Boolean) { + Log_OC.d(TAG, "Download completed broadcast sent") + + val intent = Intent(ACTION_DOWNLOAD_COMPLETED).apply { + putExtra(EXTRA_ACCOUNT_NAME, accountName) + putExtra(EXTRA_REMOTE_PATH, remotePath) + putExtra(EXTRA_DOWNLOAD_RESULT, success) + setPackage(packageName) + } + + broadcastManager.sendBroadcast(intent) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadHelper.kt new file mode 100644 index 000000000000..240fda49ef75 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadHelper.kt @@ -0,0 +1,145 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.download + +import com.nextcloud.client.account.User +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.operations.DownloadType +import com.owncloud.android.utils.MimeTypeUtil +import java.io.File +import javax.inject.Inject + +class FileDownloadHelper { + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + @Inject + lateinit var uploadsStorageManager: UploadsStorageManager + + companion object { + private var instance: FileDownloadHelper? = null + private const val TAG = "FileDownloadHelper" + + fun instance(): FileDownloadHelper = instance ?: synchronized(this) { + instance ?: FileDownloadHelper().also { instance = it } + } + } + + init { + MainApp.getAppComponent().inject(this) + } + + fun isDownloading(user: User?, file: OCFile?): Boolean { + if (user == null || file == null) { + return false + } + + return if (file.isFolder) { + FolderDownloadWorker.isDownloading(file.fileId) + } else { + FileDownloadWorker.isDownloading(user.accountName, file.fileId) + } + } + + fun cancelPendingOrCurrentDownloads(user: User?, files: List?) { + if (user == null || files == null) return + + files.forEach { file -> + FileDownloadWorker.cancelOperation(user.accountName, file.fileId) + backgroundJobManager.cancelFilesDownloadJob(user.accountName, file.fileId) + } + } + + fun cancelAllDownloadsForAccount(accountName: String, currentDownloadAccountName: String, fileId: Long) { + if (!accountName.equals(currentDownloadAccountName, true)) { + return + } + + FileDownloadWorker.cancelOperation(currentDownloadAccountName, fileId) + backgroundJobManager.cancelFilesDownloadJob(currentDownloadAccountName, fileId) + } + + fun saveFile(file: OCFile, currentDownload: DownloadFileOperation?, storageManager: FileDataStorageManager?) { + val syncDate = System.currentTimeMillis() + + file.apply { + lastSyncDateForProperties = syncDate + lastSyncDateForData = syncDate + isUpdateThumbnailNeeded = true + modificationTimestamp = currentDownload?.modificationTimestamp ?: 0L + modificationTimestampAtLastSyncForData = currentDownload?.modificationTimestamp ?: 0L + etag = currentDownload?.etag + mimeType = currentDownload?.mimeType + storagePath = currentDownload?.savePath + + val savePathFile = currentDownload?.savePath?.let { File(it) } + savePathFile?.let { + fileLength = savePathFile.length() + } + + remoteId = currentDownload?.file?.remoteId + } + + storageManager?.saveFile(file) + + if (MimeTypeUtil.isMedia(currentDownload?.mimeType)) { + FileDataStorageManager.triggerMediaScan(file.storagePath, file) + } + + storageManager?.saveConflict(file, null) + } + + fun downloadFileIfNotStartedBefore(user: User, file: OCFile) { + if (!isDownloading(user, file)) { + downloadFile(user, file, downloadType = DownloadType.DOWNLOAD) + } + } + + fun downloadFile(user: User, file: OCFile) { + downloadFile(user, file, downloadType = DownloadType.DOWNLOAD) + } + + @Suppress("LongParameterList") + fun downloadFile( + user: User, + ocFile: OCFile, + behaviour: String = "", + downloadType: DownloadType? = DownloadType.DOWNLOAD, + activityName: String = "", + packageName: String = "", + conflictUploadId: Long? = null + ) { + backgroundJobManager.startFileDownloadJob( + user, + ocFile, + behaviour, + downloadType, + activityName, + packageName, + conflictUploadId + ) + } + + fun downloadFolder(folder: OCFile?, accountName: String) { + if (folder == null) { + Log_OC.e(TAG, "folder cannot be null, cant sync") + return + } + backgroundJobManager.downloadFolder(folder, accountName) + } + + fun cancelFolderDownload() = backgroundJobManager.cancelFolderDownload() +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadIntents.kt b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadIntents.kt new file mode 100644 index 000000000000..f54a685b807c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadIntents.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.download + +import android.content.Context +import android.content.Intent +import com.nextcloud.client.account.User +import com.owncloud.android.authentication.AuthenticatorActivity +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.preview.PreviewImageActivity +import com.owncloud.android.ui.preview.PreviewImageFragment + +class FileDownloadIntents(private val context: Context) { + + fun credentialContentIntent(user: User): Intent = Intent(context, AuthenticatorActivity::class.java).apply { + putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, user.toPlatformAccount()) + putExtra( + AuthenticatorActivity.EXTRA_ACTION, + AuthenticatorActivity.ACTION_UPDATE_EXPIRED_TOKEN + ) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + addFlags(Intent.FLAG_FROM_BACKGROUND) + } + + fun detailsIntent(operation: DownloadFileOperation?): Intent = if (operation != null) { + if (PreviewImageFragment.canBePreviewed(operation.file)) { + Intent(context, PreviewImageActivity::class.java) + } else { + Intent(context, FileDisplayActivity::class.java) + }.apply { + putExtra(FileActivity.EXTRA_FILE, operation.file) + putExtra(FileActivity.EXTRA_USER, operation.user) + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + } + } else { + Intent() + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt new file mode 100644 index 000000000000..d9febd6e48b3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt @@ -0,0 +1,458 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.download + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.OnAccountsUpdateListener +import android.app.PendingIntent +import android.content.Context +import android.util.Pair +import androidx.core.util.component1 +import androidx.core.util.component2 +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.utils.ForegroundServiceHelper +import com.nextcloud.utils.extensions.getPercent +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.ForegroundServiceType +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.services.IndexedForest +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.operations.DownloadType +import com.owncloud.android.ui.events.EventBusFactory +import com.owncloud.android.ui.events.FileDownloadProgressEvent +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.util.AbstractList +import java.util.Optional +import java.util.Vector +import kotlin.random.Random + +@Suppress("LongParameterList", "TooManyFunctions", "TooGenericExceptionCaught") +class FileDownloadWorker( + viewThemeUtils: ViewThemeUtils, + private val accountManager: UserAccountManager, + localBroadcastManager: LocalBroadcastManager, + private val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params), + OnAccountsUpdateListener, + OnDatatransferProgressListener { + + companion object { + private const val TAG = "🗄️" + "FileDownloadWorker" + + private val pendingDownloads = IndexedForest() + + fun cancelOperation(accountName: String, fileId: Long) { + pendingDownloads.all.forEach { + it.value?.payload?.cancelMatchingOperation(accountName, fileId) + } + } + + fun isDownloading(accountName: String, fileId: Long): Boolean = pendingDownloads.all.any { + it.value?.payload?.isMatching(accountName, fileId) == true + } + + const val FILE_REMOTE_PATH = "FILE_REMOTE_PATH" + const val ACCOUNT_NAME = "ACCOUNT_NAME" + const val BEHAVIOUR = "BEHAVIOUR" + const val DOWNLOAD_TYPE = "DOWNLOAD_TYPE" + const val ACTIVITY_NAME = "ACTIVITY_NAME" + const val PACKAGE_NAME = "PACKAGE_NAME" + const val CONFLICT_UPLOAD_ID = "CONFLICT_UPLOAD_ID" + } + + private var currentDownload: DownloadFileOperation? = null + + private var conflictUploadId: Long? = null + private var lastPercent = 0 + + private val fileDownloadEventBroadcaster = FileDownloadEventBroadcaster(context, localBroadcastManager) + private val intents = FileDownloadIntents(context) + + private var notificationManager = DownloadNotificationManager( + Random.nextInt(), + context, + viewThemeUtils + ) + + private var downloadProgressListener = FileDownloadProgressListener() + + private var user: User? = null + private var currentUser = Optional.empty() + + private var currentUserFileStorageManager: FileDataStorageManager? = null + private var fileDataStorageManager: FileDataStorageManager? = null + + private var downloadError: FileDownloadError? = null + + @Suppress("ReturnCount") + override suspend fun doWork(): Result { + trySetForeground() + + return try { + setUser() + val remotePath = inputData.keyValueMap[FILE_REMOTE_PATH] as? String? ?: return Result.failure() + val ocFile = fileDataStorageManager?.getFileByEncryptedRemotePath(remotePath) ?: return Result.failure() + val requestDownloads = getRequestDownloads(ocFile) + addAccountUpdateListener() + + requestDownloads.forEach { + downloadFile(it) + } + + downloadError?.let { + showDownloadErrorNotification(it) + notificationManager.dismissNotification() + } + + Log_OC.d(TAG, "successfully completed") + Result.success() + } catch (t: Throwable) { + notificationManager.showNewNotification(context.getString(R.string.downloader_unexpected_error)) + Log_OC.e(TAG, "exception: " + t.localizedMessage) + Result.failure() + } finally { + Log_OC.d(TAG, "cleanup") + notificationManager.dismissNotification() + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = notificationManager.getNotification() + return ForegroundServiceHelper.createWorkerForegroundInfo( + notificationManager.getId(), + notification, + ForegroundServiceType.DataSync + ) + } + + private suspend fun trySetForeground() { + try { + val foregroundInfo = createWorkerForegroundInfo() + setForeground(foregroundInfo) + } catch (e: Exception) { + Log_OC.w(TAG, "⚠️ Could not set foreground service: ${e.message}") + } + } + + private fun createWorkerForegroundInfo(): ForegroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( + notificationManager.getId(), + notificationManager.getNotification(), + ForegroundServiceType.DataSync + ) + + private fun removePendingDownload(accountName: String?) { + pendingDownloads.remove(accountName) + } + + private fun getRequestDownloads(ocFile: OCFile): AbstractList { + val files = getFiles(ocFile) + val downloadType = getDownloadType() + + conflictUploadId = inputData.keyValueMap[CONFLICT_UPLOAD_ID] as Long? + + val behaviour = inputData.keyValueMap[BEHAVIOUR] as String? ?: "" + val activityName = inputData.keyValueMap[ACTIVITY_NAME] as String? ?: "" + val packageName = inputData.keyValueMap[PACKAGE_NAME] as String? ?: "" + + val requestedDownloads: AbstractList = Vector() + + return try { + files.forEach { file -> + val operation = DownloadFileOperation( + user, + file, + behaviour, + activityName, + packageName, + context, + downloadType + ) + + operation.addDownloadDataTransferProgressListener(this) + operation.addDownloadDataTransferProgressListener(downloadProgressListener) + val (downloadKey, _) = pendingDownloads.putIfAbsent( + user?.accountName, + file.remotePath, + operation + ) ?: Pair(null, null) + + downloadKey?.let { + requestedDownloads.add(downloadKey) + } + + fileDownloadEventBroadcaster.sendDownloadEnqueued( + operation.user.accountName, + operation.remotePath, + context.packageName, + operation.file.fileId, + operation.user.accountName + ) + } + + requestedDownloads + } catch (e: IllegalArgumentException) { + Log_OC.e(TAG, "Not enough information provided in intent: " + e.message) + requestedDownloads + } + } + + private fun setUser() { + val accountName = inputData.keyValueMap[ACCOUNT_NAME] as String + user = accountManager.getUser(accountName).get() + fileDataStorageManager = FileDataStorageManager(user, context.contentResolver) + } + + private fun getFiles(file: OCFile): List = if (file.isFolder) { + fileDataStorageManager?.getAllFilesRecursivelyInsideFolder(file) ?: listOf() + } else { + listOf(file) + } + + private fun getDownloadType(): DownloadType? { + val typeAsString = inputData.keyValueMap[DOWNLOAD_TYPE] as String? + return if (typeAsString != null) { + if (typeAsString == DownloadType.DOWNLOAD.toString()) { + DownloadType.DOWNLOAD + } else { + DownloadType.EXPORT + } + } else { + null + } + } + + private fun addAccountUpdateListener() { + val am = AccountManager.get(context) + am.addOnAccountsUpdatedListener(this, null, false) + } + + @Suppress("TooGenericExceptionCaught", "DEPRECATION") + private fun downloadFile(downloadKey: String) { + currentDownload = pendingDownloads.get(downloadKey) + + if (currentDownload == null) { + return + } + + Log_OC.d(TAG, "downloading: $downloadKey") + + val isAccountExist = accountManager.exists(currentDownload?.user?.toPlatformAccount()) + if (!isAccountExist) { + removePendingDownload(currentDownload?.user?.accountName) + return + } + + lastPercent = 0 + notificationManager.run { + prepareForStart(currentDownload!!) + setContentIntent(intents.detailsIntent(currentDownload!!), PendingIntent.FLAG_IMMUTABLE) + } + + var downloadResult: RemoteOperationResult<*>? = null + try { + val ocAccount = getOCAccountForDownload() + val downloadClient = + OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) + + downloadResult = currentDownload?.execute(downloadClient) + if (downloadResult?.isSuccess == true && currentDownload?.downloadType === DownloadType.DOWNLOAD) { + getCurrentFile()?.let { + FileDownloadHelper.instance().saveFile(it, currentDownload, currentUserFileStorageManager) + } + } + } catch (e: Exception) { + Log_OC.e(TAG, "exception downloading file: ", e) + downloadResult = RemoteOperationResult(e) + } finally { + cleanupDownloadProcess(downloadResult) + } + } + + @Suppress("DEPRECATION") + private fun getOCAccountForDownload(): OwnCloudAccount { + val currentDownloadAccount = currentDownload?.user?.toPlatformAccount() + val currentDownloadUser = accountManager.getUser(currentDownloadAccount?.name) + if (currentUser != currentDownloadUser) { + currentUser = currentDownloadUser + currentUserFileStorageManager = FileDataStorageManager(currentUser.get(), context.contentResolver) + } + return currentDownloadUser.get().toOwnCloudAccount() + } + + private fun getCurrentFile(): OCFile? { + var file: OCFile? = currentDownload?.file?.fileId?.let { currentUserFileStorageManager?.getFileById(it) } + + if (file == null) { + file = currentUserFileStorageManager?.getFileByDecryptedRemotePath(currentDownload?.file?.remotePath) + } + + if (file == null) { + Log_OC.e(this, "Could not save " + currentDownload?.file?.remotePath) + return null + } + + return file + } + + private fun cleanupDownloadProcess(result: RemoteOperationResult<*>?) { + result?.let { + checkDownloadError(it) + } + + pendingDownloads.removePayload( + currentDownload?.user?.accountName, + currentDownload?.remotePath + ) + + val downloadResult = result ?: RemoteOperationResult(RuntimeException("Error downloading…")) + + currentDownload?.run { + notifyDownloadResult(this, downloadResult) + + fileDownloadEventBroadcaster.sendDownloadCompleted( + this, + downloadResult + ) + } + } + + private fun checkDownloadError(result: RemoteOperationResult<*>) { + if (result.isSuccess || downloadError != null) { + notificationManager.dismissNotification() + return + } + + downloadError = if (result.isCancelled) { + FileDownloadError.Cancelled + } else { + FileDownloadError.Failed + } + } + + private fun showDownloadErrorNotification(downloadError: FileDownloadError) { + val text = when (downloadError) { + FileDownloadError.Cancelled -> { + context.getString(R.string.downloader_file_download_cancelled) + } + + FileDownloadError.Failed -> { + context.getString(R.string.downloader_file_download_failed) + } + } + + notificationManager.showNewNotification(text) + } + + private fun notifyDownloadResult(download: DownloadFileOperation, downloadResult: RemoteOperationResult<*>) { + if (downloadResult.isCancelled) { + return + } + + val needsToUpdateCredentials = (ResultCode.UNAUTHORIZED == downloadResult.code) + notificationManager.run { + prepareForResult() + + if (needsToUpdateCredentials) { + showNewNotification(context.getString(R.string.downloader_download_failed_credentials_error)) + setContentIntent( + intents.credentialContentIntent(download.user), + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + setContentIntent(intents.detailsIntent(null), PendingIntent.FLAG_IMMUTABLE) + } + } + } + + @Suppress("DEPRECATION") + override fun onAccountsUpdated(accounts: Array?) { + if (!accountManager.exists(currentDownload?.user?.toPlatformAccount())) { + currentDownload?.cancel() + } + } + + @Suppress("MagicNumber") + private val minProgressUpdateInterval = 750 + private var lastUpdateTime = 0L + + @Suppress("MagicNumber") + override fun onTransferProgress( + progressRate: Long, + totalTransferredSoFar: Long, + totalToTransfer: Long, + filePath: String + ) { + val percent: Int = downloadProgressListener.getPercent(totalTransferredSoFar, totalToTransfer) + val currentTime = System.currentTimeMillis() + + if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) { + notificationManager.run { + updateDownloadProgress(percent, totalToTransfer) + } + lastUpdateTime = currentTime + } + + lastPercent = percent + EventBusFactory.downloadProgressEventBus.post(FileDownloadProgressEvent(percent)) + } + + // CHECK: Is this class still needed after conversion from Foreground Services to Worker? + inner class FileDownloadProgressListener : OnDatatransferProgressListener { + private val boundListeners: MutableMap = HashMap() + + fun isDownloading(user: User?, file: OCFile?): Boolean = FileDownloadHelper.instance().isDownloading(user, file) + + fun addDataTransferProgressListener(listener: OnDatatransferProgressListener?, file: OCFile?) { + if (file == null || listener == null) { + return + } + + boundListeners[file.fileId] = listener + } + + fun removeDataTransferProgressListener(listener: OnDatatransferProgressListener?, file: OCFile?) { + if (file == null || listener == null) { + return + } + + val fileId = file.fileId + if (boundListeners[fileId] === listener) { + boundListeners.remove(fileId) + } + } + + override fun onTransferProgress( + progressRate: Long, + totalTransferredSoFar: Long, + totalToTransfer: Long, + fileName: String + ) { + val listener = boundListeners[currentDownload?.file?.fileId] + listener?.onTransferProgress( + progressRate, + totalTransferredSoFar, + totalToTransfer, + fileName + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadEventBroadcaster.kt b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadEventBroadcaster.kt new file mode 100644 index 000000000000..7bd36992902e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadEventBroadcaster.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.folderDownload + +import android.content.Context +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.owncloud.android.lib.common.utils.Log_OC + +class FolderDownloadEventBroadcaster( + private val context: Context, + private val broadcastManager: LocalBroadcastManager +) { + companion object { + private const val TAG = "📣" + "FolderDownloadBroadcastManager" + + private const val PREFIX = "com.nextcloud.client.folderDownload." + + const val ACTION_DOWNLOAD_ENQUEUED = PREFIX + "ACTION_DOWNLOAD_ENQUEUED" + const val ACTION_DOWNLOAD_COMPLETED = PREFIX + "ACTION_DOWNLOAD_COMPLETED" + + const val EXTRA_FILE_ID = PREFIX + "EXTRA_FILE_ID" + } + + fun sendDownloadEnqueued(id: Long) { + Log_OC.d(TAG, "Download enqueued broadcast sent") + + val intent = Intent(ACTION_DOWNLOAD_ENQUEUED).apply { + putExtra(EXTRA_FILE_ID, id) + } + + broadcastManager.sendBroadcast(intent) + } + + fun sendDownloadCompleted(id: Long) { + Log_OC.d(TAG, "Download completed broadcast sent") + + val intent = Intent(ACTION_DOWNLOAD_COMPLETED).apply { + putExtra(EXTRA_FILE_ID, id) + } + + broadcastManager.sendBroadcast(intent) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorker.kt new file mode 100644 index 000000000000..cc0ec499b196 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorker.kt @@ -0,0 +1,202 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.folderDownload + +import android.content.Context +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.operations.DownloadType +import com.owncloud.android.ui.helpers.FileOperationsHelper +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +@Suppress("LongMethod", "TooGenericExceptionCaught") +class FolderDownloadWorker( + private val accountManager: UserAccountManager, + private val context: Context, + viewThemeUtils: ViewThemeUtils, + localBroadcastManager: LocalBroadcastManager, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "📂" + "FolderDownloadWorker" + const val FOLDER_ID = "FOLDER_ID" + const val ACCOUNT_NAME = "ACCOUNT_NAME" + + private val pendingDownloads: MutableSet = ConcurrentHashMap.newKeySet() + + fun isDownloading(id: Long): Boolean = pendingDownloads.contains(id) + } + + private val notificationManager = FolderDownloadWorkerNotificationManager(context, viewThemeUtils) + private val folderDownloadEventBroadcaster = FolderDownloadEventBroadcaster(context, localBroadcastManager) + private lateinit var storageManager: FileDataStorageManager + + @Suppress("ReturnCount", "DEPRECATION") + override suspend fun doWork(): Result { + val folderID = inputData.getLong(FOLDER_ID, -1) + if (folderID == -1L) { + return Result.failure() + } + + val accountName = inputData.getString(ACCOUNT_NAME) + if (accountName == null) { + Log_OC.e(TAG, "failed accountName cannot be null") + return Result.failure() + } + + val optionalUser = accountManager.getUser(accountName) + if (optionalUser.isEmpty) { + Log_OC.e(TAG, "failed user is not present") + return Result.failure() + } + + val user = optionalUser.get() + storageManager = FileDataStorageManager(user, context.contentResolver) + val folder = storageManager.getFileById(folderID) + if (folder == null) { + Log_OC.e(TAG, "failed folder cannot be nul") + return Result.failure() + } + + Log_OC.d(TAG, "🕒 started for ${user.accountName} downloading ${folder.fileName}") + + trySetForeground(folder) + + folderDownloadEventBroadcaster.sendDownloadEnqueued(folder.fileId) + pendingDownloads.add(folder.fileId) + + val downloadHelper = FileDownloadHelper.instance() + + return withContext(Dispatchers.IO) { + try { + val files = getFiles(folder, storageManager) + val account = user.toOwnCloudAccount() + val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(account, context) + + var result = true + files.forEachIndexed { index, file -> + if (!checkDiskSize(file)) { + return@withContext Result.failure() + } + + withContext(Dispatchers.Main) { + val notification = notificationManager.getProgressNotification( + folder.fileName, + file.fileName, + index, + files.size + ) + notificationManager.showNotification(notification) + + val foregroundInfo = notificationManager.getForegroundInfo(notification) + setForeground(foregroundInfo) + } + + val operation = DownloadFileOperation(user, file, context) + val operationResult = operation.execute(client) + if (operationResult?.isSuccess == true && operation.downloadType === DownloadType.DOWNLOAD) { + getOCFile(operation)?.let { ocFile -> + downloadHelper.saveFile(ocFile, operation, storageManager) + } + } + + if (!operationResult.isSuccess) { + result = false + } + } + + withContext(Dispatchers.Main) { + notificationManager.showCompletionNotification(folder.fileName, result) + } + + if (result) { + Log_OC.d(TAG, "✅ completed") + Result.success() + } else { + Log_OC.d(TAG, "❌ failed") + Result.failure() + } + } catch (e: Exception) { + Log_OC.d(TAG, "❌ failed reason: $e") + Result.failure() + } finally { + folderDownloadEventBroadcaster.sendDownloadCompleted(folder.fileId) + pendingDownloads.remove(folder.fileId) + notificationManager.dismiss() + } + } + } + + @Suppress("ReturnCount") + override suspend fun getForegroundInfo(): ForegroundInfo { + return try { + val folderID = inputData.getLong(FOLDER_ID, -1) + val accountName = inputData.getString(ACCOUNT_NAME) + + if (folderID == -1L || accountName == null || !::storageManager.isInitialized) { + return notificationManager.getForegroundInfo(null) + } + + val folder = storageManager.getFileById(folderID) ?: return notificationManager.getForegroundInfo(null) + + return notificationManager.getForegroundInfo(folder) + } catch (e: Exception) { + Log_OC.w(TAG, "⚠️ Error getting foreground info: ${e.message}") + notificationManager.getForegroundInfo(null) + } + } + + private suspend fun trySetForeground(folder: OCFile) { + try { + val foregroundInfo = notificationManager.getForegroundInfo(folder) + setForeground(foregroundInfo) + } catch (e: Exception) { + Log_OC.w(TAG, "⚠️ Could not set foreground service: ${e.message}") + } + } + + private fun getOCFile(operation: DownloadFileOperation): OCFile? { + val file = operation.file?.fileId?.let { storageManager.getFileById(it) } + ?: storageManager.getFileByDecryptedRemotePath(operation.file?.remotePath) + ?: run { + Log_OC.e(TAG, "could not save ${operation.file?.remotePath}") + return null + } + + return file + } + + private fun getFiles(folder: OCFile, storageManager: FileDataStorageManager): List = + storageManager.getFolderContent(folder, false) + .filter { !it.isFolder && !it.isDown } + + private fun checkDiskSize(file: OCFile): Boolean { + val fileSizeInByte = file.fileLength + val availableDiskSpace = FileOperationsHelper.getAvailableSpaceOnDevice() + + return if (availableDiskSpace < fileSizeInByte) { + notificationManager.showNotAvailableDiskSpace() + false + } else { + true + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerNotificationManager.kt new file mode 100644 index 000000000000..fed0b8feaff0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerNotificationManager.kt @@ -0,0 +1,113 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.folderDownload + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.work.ForegroundInfo +import com.nextcloud.client.jobs.notification.WorkerNotificationManager +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlin.random.Random + +class FolderDownloadWorkerNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils) : + WorkerNotificationManager( + id = NOTIFICATION_ID, + context, + viewThemeUtils, + tickerId = R.string.folder_download_worker_ticker_id, + channelId = NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD + ) { + + companion object { + private const val NOTIFICATION_ID = 391 + private const val MAX_PROGRESS = 100 + } + + private fun getNotification(title: String, description: String = "", progress: Int? = null): Notification = + notificationBuilder.apply { + setSmallIcon(R.drawable.ic_sync) + setContentTitle(title) + clearActions() + setContentText(description) + + if (progress != null) { + setProgress(MAX_PROGRESS, progress, false) + addAction( + R.drawable.ic_cancel, + context.getString(R.string.common_cancel), + getCancelPendingIntent() + ) + } else { + setProgress(0, 0, false) + } + + setAutoCancel(true) + }.build() + + private fun getCancelPendingIntent(): PendingIntent { + val intent = Intent(context, FolderDownloadWorkerReceiver::class.java) + + return PendingIntent.getBroadcast( + context, + Random.nextInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + @Suppress("MagicNumber") + fun getProgressNotification( + folderName: String, + filename: String, + currentIndex: Int, + totalFileSize: Int + ): Notification { + val currentFileIndex = (currentIndex + 1) + val description = context.getString(R.string.folder_download_counter, currentFileIndex, totalFileSize, filename) + val progress = (currentFileIndex * MAX_PROGRESS) / totalFileSize + return getNotification(folderName, description, progress) + } + + fun showCompletionNotification(folderName: String, success: Boolean) { + val titleId = if (success) { + R.string.folder_download_success_notification_title + } else { + R.string.folder_download_error_notification_title + } + + val title = context.getString(titleId, folderName) + + val notification = getNotification(title) + notificationManager.notify(NOTIFICATION_ID, notification) + } + + fun showNotAvailableDiskSpace() { + val title = context.getString(R.string.folder_download_insufficient_disk_space_notification_title) + val notification = getNotification(title) + notificationManager.notify(NOTIFICATION_ID, notification) + } + + fun getForegroundInfo(folder: OCFile?): ForegroundInfo { + val notification = if (folder != null) { + getNotification(folder.fileName) + } else { + getNotification(title = context.getString(R.string.folder_download_worker_ticker_id)) + } + + return getForegroundInfo(notification) + } + + fun dismiss() { + notificationManager.cancel(NOTIFICATION_ID) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerReceiver.kt b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerReceiver.kt new file mode 100644 index 000000000000..399c1ccb868e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerReceiver.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.folderDownload + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.nextcloud.client.jobs.BackgroundJobManager +import com.owncloud.android.MainApp +import javax.inject.Inject + +class FolderDownloadWorkerReceiver : BroadcastReceiver() { + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + override fun onReceive(context: Context, intent: Intent) { + MainApp.getAppComponent().inject(this) + backgroundJobManager.cancelFolderDownload() + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt new file mode 100644 index 000000000000..2e532c00ac88 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt @@ -0,0 +1,224 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.gallery + +import android.graphics.Bitmap +import android.graphics.Point +import android.media.ThumbnailUtils +import android.os.Build +import android.provider.MediaStore +import android.util.Size +import android.view.WindowManager +import android.widget.ImageView +import androidx.core.content.ContextCompat +import com.nextcloud.client.account.User +import com.nextcloud.utils.extensions.isPNG +import com.nextcloud.utils.extensions.toFile +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.MimeTypeUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import java.util.Collections +import java.util.WeakHashMap + +@Suppress("DEPRECATION", "TooGenericExceptionCaught", "ReturnCount") +class GalleryImageGenerationJob(private val user: User, private val storageManager: FileDataStorageManager) { + + companion object { + private const val TAG = "GalleryImageGenerationJob" + private val semaphore = Semaphore(maxOf(3, Runtime.getRuntime().availableProcessors() / 2)) + private val activeJobs = Collections.synchronizedMap(WeakHashMap()) + + fun cancelAllActiveJobs() { + val jobsToCancel = synchronized(activeJobs) { + val list = activeJobs.values.toList() + activeJobs.clear() + list + } + for (job in jobsToCancel) { + job.cancel() + } + } + + fun removeActiveJob(imageView: ImageView, job: Job) { + synchronized(activeJobs) { + if (isActiveJob(imageView, job)) { + removeJob(imageView) + } + } + } + + fun isActiveJob(imageView: ImageView, job: Job): Boolean = synchronized(activeJobs) { + activeJobs[imageView] === job + } + + fun storeJob(job: Job, imageView: ImageView) { + synchronized(activeJobs) { + activeJobs[imageView] = job + } + } + + fun cancelPreviousJob(imageView: ImageView) { + synchronized(activeJobs) { + activeJobs[imageView]?.cancel() + activeJobs.remove(imageView) + } + } + + fun removeJob(imageView: ImageView) { + synchronized(activeJobs) { + activeJobs.remove(imageView) + } + } + } + + suspend fun run(file: OCFile, imageView: ImageView, listener: GalleryImageGenerationListener) { + try { + if (file.remoteId == null && !file.isPreviewAvailable) { + Log_OC.e(TAG, "file has no remoteId and no preview") + withContext(Dispatchers.Main) { listener.onError() } + return + } + + var newImage = false + val bitmap: Bitmap? = getBitmap(file, onNewThumbnail = { newImage = true }) + + if (bitmap == null) { + withContext(Dispatchers.Main) { listener.onError() } + return + } + + setThumbnail(bitmap, file, imageView, newImage, listener) + } catch (_: Exception) { + withContext(Dispatchers.Main) { listener.onError() } + } + } + + private suspend fun getBitmap(file: OCFile, onNewThumbnail: () -> Unit): Bitmap? = withContext(Dispatchers.IO) { + val cacheKey = ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE + file.remoteId + + val cached = ThumbnailsCacheManager.getBitmapFromDiskCache(cacheKey) + if (cached != null && !file.isUpdateThumbnailNeeded) { + return@withContext applyVideoOverlayIfNeeded(file, cached) + } + + onNewThumbnail() + + val local = decodeLocalThumbnail(file) + if (local != null) { + ThumbnailsCacheManager.addBitmapToCache(cacheKey, local) + return@withContext applyVideoOverlayIfNeeded(file, local) + } + + val remote = semaphore.withPermit { fetchFromServer(file) } + if (remote != null) { + return@withContext applyVideoOverlayIfNeeded(file, remote) + } + + null + } + + private fun decodeLocalThumbnail(file: OCFile): Bitmap? = if (MimeTypeUtil.isVideo(file)) { + createVideoThumbnail(file.storagePath) + } else { + createImageThumbnail(file) + } + + private fun createImageThumbnail(file: OCFile): Bitmap? { + val wm = MainApp.getAppContext().getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager + val p = Point() + wm.defaultDisplay.getSize(p) + + val pxW = p.x + val pxH = p.y + + val cacheKey = ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE + file.remoteId + + var bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, pxW, pxH) ?: return null + + if (file.isPNG()) { + bitmap = ThumbnailsCacheManager.handlePNG(bitmap, pxW, pxH) + } + + val thumbnail = ThumbnailsCacheManager.addThumbnailToCache(cacheKey, bitmap, file.storagePath, pxW, pxH) + file.isUpdateThumbnailNeeded = false + + return thumbnail + } + + private fun createVideoThumbnail(storagePath: String): Bitmap? { + val ioFile = storagePath.toFile() ?: return null + val size = ThumbnailsCacheManager.getThumbnailDimension() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { + ThumbnailUtils.createVideoThumbnail(ioFile, Size(size, size), null) + } catch (e: Exception) { + Log_OC.e(TAG, "Failed to create video thumbnail from local file: ${e.message}") + null + } + } else { + @Suppress("DEPRECATION") + ThumbnailUtils.createVideoThumbnail(storagePath, MediaStore.Images.Thumbnails.MINI_KIND) + } + } + + private suspend fun fetchFromServer(file: OCFile): Bitmap? = try { + val client = withContext(Dispatchers.IO) { + OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(user.toOwnCloudAccount(), MainApp.getAppContext()) + } + ThumbnailsCacheManager.setClient(client) + ThumbnailsCacheManager.doResizedImageInBackground(file, storageManager) + } catch (t: Throwable) { + Log_OC.e(TAG, "Server fetch failed for $file", t) + null + } + + private fun applyVideoOverlayIfNeeded(file: OCFile, bitmap: Bitmap): Bitmap = if (MimeTypeUtil.isVideo(file)) { + ThumbnailsCacheManager.addVideoOverlay(bitmap, MainApp.getAppContext()) + } else { + bitmap + } + + private suspend fun setThumbnail( + bitmap: Bitmap, + file: OCFile, + imageView: ImageView, + newImage: Boolean, + listener: GalleryImageGenerationListener + ) = withContext(Dispatchers.Main) { + val tagId = file.fileId.toString() + + if (imageView.tag.toString() == tagId) { + if (file.isPNG()) { + imageView.setBackgroundColor( + ContextCompat.getColor(MainApp.getAppContext(), R.color.bg_default) + ) + } + + if (newImage) listener.onNewGalleryImage() + + if (imageView.isAttachedToWindow) { + imageView.setImageBitmap(bitmap) + imageView.invalidate() + } + } + + listener.onSuccess() + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationListener.kt b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationListener.kt new file mode 100644 index 000000000000..e7ca171a1151 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationListener.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.gallery + +interface GalleryImageGenerationListener { + fun onSuccess() + fun onNewGalleryImage() + fun onError() +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/metadata/MetadataWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/metadata/MetadataWorker.kt new file mode 100644 index 000000000000..5221bbe3d663 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/metadata/MetadataWorker.kt @@ -0,0 +1,130 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.metadata + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.utils.extensions.getNonEncryptedSubfolders +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.RefreshFolderOperation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Suppress("DEPRECATION", "ReturnCount", "TooGenericExceptionCaught") +class MetadataWorker(private val context: Context, params: WorkerParameters, private val user: User) : + CoroutineWorker(context, params) { + + companion object { + private const val TAG = "MetadataWorker" + const val FILE_PATH = "file_path" + } + + override suspend fun doWork(): Result { + val filePath = inputData.getString(FILE_PATH) + if (filePath == null) { + Log_OC.e(TAG, "❌ Invalid folder path. Aborting metadata sync. $filePath") + return Result.failure() + } + + if (user.isAnonymous) { + Log_OC.w(TAG, "user is anonymous cannot start metadata worker") + return Result.failure() + } + + val storageManager = FileDataStorageManager(user, context.contentResolver) + val currentDir = storageManager.getFileByDecryptedRemotePath(filePath) + if (currentDir == null) { + Log_OC.e(TAG, "❌ Current directory is null. Aborting metadata sync. $filePath") + return Result.failure() + } + if (!currentDir.hasValidParentId()) { + Log_OC.e(TAG, "❌ Current directory has invalid ID: ${currentDir.fileId}. Path: $filePath") + return Result.failure() + } + + Log_OC.d(TAG, "🕒 Starting metadata sync for folder: $filePath, id: ${currentDir.fileId}") + + // First check current dir + val currentRefreshResult = refreshFolder(currentDir, storageManager) + if (!currentRefreshResult) { + Log_OC.e(TAG, "❌ Failed to refresh current directory: $filePath") + return Result.failure() + } + + // Re-fetch the folder after refresh to get updated data + val refreshedDir = storageManager.getFileByPath(filePath) + if (refreshedDir == null || !refreshedDir.hasValidParentId()) { + Log_OC.e(TAG, "❌ Directory invalid after refresh. Path: $filePath") + return Result.failure() + } + + // then get up-to-date subfolders + val subfolders = storageManager.getNonEncryptedSubfolders(refreshedDir.fileId, user.accountName) + Log_OC.d(TAG, "Found ${subfolders.size} subfolders to sync") + + var failedCount = 0 + subfolders.forEach { subFolder -> + if (!subFolder.hasValidParentId()) { + Log_OC.e(TAG, "❌ Skipping subfolder with invalid ID: ${subFolder.remotePath}") + failedCount++ + return@forEach + } + + val success = refreshFolder(subFolder, storageManager) + if (!success) { + failedCount++ + } + } + + Log_OC.d(TAG, "🏁 Metadata sync completed for folder: $filePath. Failed: $failedCount/${subfolders.size}") + + return Result.success() + } + + @Suppress("DEPRECATION") + private suspend fun refreshFolder(folder: OCFile, storageManager: FileDataStorageManager): Boolean = + withContext(Dispatchers.IO) { + Log_OC.d( + TAG, + "📂 eTag check\n" + + " Path: " + folder.remotePath + "\n" + + " eTag: " + folder.etag + "\n" + + " eTagOnServer: " + folder.etagOnServer + ) + if (!folder.hasValidParentId()) { + Log_OC.e(TAG, "❌ Folder has invalid ID: ${folder.remotePath}") + return@withContext false + } + + if (!folder.isEtagChanged) { + Log_OC.d(TAG, "Skipping ${folder.remotePath}, eTag didn't change") + return@withContext true + } + + Log_OC.d(TAG, "⏳ Fetching metadata for: ${folder.remotePath}, id: ${folder.fileId}") + + val operation = RefreshFolderOperation(folder, storageManager, user, context) + return@withContext try { + val result = operation.execute(user, context) + if (result.isSuccess) { + Log_OC.d(TAG, "✅ Successfully fetched metadata for: ${folder.remotePath}") + true + } else { + Log_OC.e(TAG, "❌ Failed to fetch metadata for: ${folder.remotePath}") + false + } + } catch (e: Exception) { + Log_OC.e(TAG, "❌ Exception refreshing folder ${folder.remotePath}: ${e.message}", e) + false + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt new file mode 100644 index 000000000000..195a11b72a34 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.notification + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.graphics.BitmapFactory +import android.os.Handler +import android.os.Looper +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import com.nextcloud.utils.ForegroundServiceHelper +import com.owncloud.android.R +import com.owncloud.android.datamodel.ForegroundServiceType +import com.owncloud.android.utils.theme.ViewThemeUtils + +open class WorkerNotificationManager( + private val id: Int, + private val context: Context, + viewThemeUtils: ViewThemeUtils, + private val tickerId: Int, + channelId: String +) { + var currentOperationTitle: String? = null + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + var notificationBuilder: NotificationCompat.Builder = + NotificationCompat.Builder(context, channelId).apply { + setTicker(context.getString(tickerId)) + setSmallIcon(R.drawable.notification_icon) + setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon)) + setStyle(NotificationCompat.BigTextStyle()) + priority = NotificationCompat.PRIORITY_LOW + setSound(null) + setVibrate(null) + setOnlyAlertOnce(true) + setSilent(true) + viewThemeUtils.androidx.themeNotificationCompatBuilder(context, this) + } + + fun showNotification() { + notificationManager.notify(id, notificationBuilder.build()) + } + + fun showNotification(notification: Notification) { + notificationManager.notify(id, notification) + } + + fun showNotification(id: Int, notification: Notification) { + notificationManager.notify(id, notification) + } + + fun dismissNotification(id: Int) { + notificationManager.cancel(id) + } + + @Suppress("MagicNumber") + fun setProgress(percent: Int, progressText: String?, indeterminate: Boolean) { + notificationBuilder.run { + setProgress(100, percent, indeterminate) + setContentTitle(currentOperationTitle) + + progressText?.let { + setContentText(progressText) + } + } + } + + fun dismissNotification(delay: Long = 0) { + Handler(Looper.getMainLooper()).postDelayed({ + notificationManager.cancel(id) + }, delay) + } + + fun getId(): Int = id + + fun getNotification(): Notification = notificationBuilder.build() + + fun getForegroundInfo(notification: Notification): ForegroundInfo = + ForegroundServiceHelper.createWorkerForegroundInfo( + id, + notification, + ForegroundServiceType.DataSync + ) +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt new file mode 100644 index 000000000000..fce69c3c71a4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt @@ -0,0 +1,168 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.offlineOperations + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.nextcloud.client.database.entity.OfflineOperationEntity +import com.nextcloud.client.jobs.notification.WorkerNotificationManager +import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver +import com.nextcloud.utils.extensions.getErrorMessage +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.ui.activity.ConflictsResolveActivity +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils + +class OfflineOperationsNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils) : + WorkerNotificationManager( + ID, + context, + viewThemeUtils, + tickerId = R.string.offline_operations_worker_notification_manager_ticker, + channelId = NotificationUtils.NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS + ) { + + companion object { + private const val ID = 121 + const val ERROR_ID = 122 + + private const val ONE_HUNDRED_PERCENT = 100 + } + + fun start() { + notificationBuilder.run { + setContentTitle(context.getString(R.string.offline_operations_worker_notification_start_text)) + setProgress(ONE_HUNDRED_PERCENT, 0, false) + } + + showNotification() + } + + fun update(totalOperationSize: Int, currentOperationIndex: Int, filename: String) { + val title = if (totalOperationSize > 1) { + String.format( + context.getString(R.string.offline_operations_worker_progress_text), + currentOperationIndex, + totalOperationSize, + filename + ) + } else { + filename + } + + val progress = (currentOperationIndex * ONE_HUNDRED_PERCENT) / totalOperationSize + + notificationBuilder.run { + setContentTitle(title) + setProgress(ONE_HUNDRED_PERCENT, progress, false) + } + + showNotification() + } + + fun showNewNotification(id: Int?, result: RemoteOperationResult<*>, operation: RemoteOperation<*>) { + val reason = (result to operation).getErrorMessage() + val text = context.getString(R.string.offline_operations_worker_notification_error_text, reason) + val cancelOfflineOperationAction = id?.let { getCancelOfflineOperationAction(it) } + + notificationBuilder.run { + cancelOfflineOperationAction?.let { + addAction(it) + } + setContentTitle(text) + setOngoing(false) + setProgress(0, 0, false) + notificationManager.notify(ERROR_ID, this.build()) + } + } + + fun showConflictNotificationForDeleteOrRemoveOperation(entity: OfflineOperationEntity?) { + val id = entity?.id + if (id == null) { + return + } + + val title = entity.getConflictText(context) + + notificationBuilder + .setProgress(0, 0, false) + .setOngoing(false) + .clearActions() + .setContentTitle(title) + + notificationManager.notify(id, notificationBuilder.build()) + } + + fun showConflictResolveNotification(file: OCFile, entity: OfflineOperationEntity?) { + val path = entity?.path + val id = entity?.id + + if (path == null || id == null) { + return + } + + val resolveConflictAction = getResolveConflictAction(file, id, path) + + val title = entity.getConflictText(context) + + notificationBuilder + .setProgress(0, 0, false) + .setOngoing(false) + .clearActions() + .setContentTitle(title) + .setContentIntent(resolveConflictAction.actionIntent) + .addAction(resolveConflictAction) + + notificationManager.notify(id, notificationBuilder.build()) + } + + private fun getResolveConflictAction(file: OCFile, id: Int, path: String): NotificationCompat.Action { + val intent = ConflictsResolveActivity.createIntent(file, path, context) + val pendingIntent = PendingIntent.getActivity( + context, + id, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Action( + R.drawable.ic_cloud_upload, + context.getString(R.string.upload_list_resolve_conflict), + pendingIntent + ) + } + + private fun getCancelOfflineOperationAction(id: Int): NotificationCompat.Action { + val intent = Intent(context, OfflineOperationReceiver::class.java).apply { + putExtra(OfflineOperationReceiver.ID, id) + } + + val pendingIntent = PendingIntent.getBroadcast( + context, + id, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Action( + R.drawable.ic_delete, + context.getString(R.string.common_cancel), + pendingIntent + ) + } + + fun dismissNotification(id: Int?) { + if (id == null) return + notificationManager.cancel(id) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt new file mode 100644 index 000000000000..b53a4cc5dc04 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt @@ -0,0 +1,305 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.offlineOperations + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.database.entity.OfflineOperationEntity +import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepository +import com.nextcloud.client.network.ClientFactoryImpl +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.model.OfflineOperationType +import com.nextcloud.model.WorkerState +import com.nextcloud.model.WorkerStateObserver +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation +import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.CreateFolderOperation +import com.owncloud.android.operations.RemoveFileOperation +import com.owncloud.android.operations.RenameFileOperation +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +private typealias OfflineOperationResult = Pair?, RemoteOperation<*>?>? + +class OfflineOperationsWorker( + private val user: User, + private val context: Context, + private val connectivityService: ConnectivityService, + viewThemeUtils: ViewThemeUtils, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private val TAG = OfflineOperationsWorker::class.java.simpleName + const val JOB_NAME = "JOB_NAME" + + private const val ONE_SECOND = 1000L + } + + private val fileDataStorageManager = FileDataStorageManager(user, context.contentResolver) + private val clientFactory = ClientFactoryImpl(context) + private val notificationManager = OfflineOperationsNotificationManager(context, viewThemeUtils) + private var repository = OfflineOperationsRepository(fileDataStorageManager) + + @Suppress("TooGenericExceptionCaught") + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + val jobName = inputData.getString(JOB_NAME) + Log_OC.d(TAG, "[$jobName] OfflineOperationsWorker started for user: ${user.accountName}") + + // check network connection + if (!isNetworkAndServerAvailable()) { + Log_OC.w(TAG, "⚠️ No internet/server connection. Retrying later...") + return@withContext Result.retry() + } + + // check offline operations + val operations = fileDataStorageManager.offlineOperationDao.getAll() + if (operations.isEmpty()) { + Log_OC.d(TAG, "Skipping, no offline operation found") + return@withContext Result.success() + } + + // process offline operations + notificationManager.start() + val client = clientFactory.create(user) + processOperations(operations, client) + + // finish + WorkerStateObserver.send(WorkerState.OfflineOperationsCompleted) + Log_OC.d(TAG, "🏁 Worker finished with result") + return@withContext Result.success() + } catch (e: Exception) { + Log_OC.e(TAG, "💥 ProcessOperations failed: ${e.message}") + return@withContext Result.failure() + } finally { + notificationManager.dismissNotification() + } + } + + // region Handle offline operations + @Suppress("TooGenericExceptionCaught") + private suspend fun processOperations(operations: List, client: OwnCloudClient) { + val totalOperationSize = operations.size + operations.forEachIndexed { index, operation -> + try { + Log_OC.d(TAG, "Processing operation, path: ${operation.path}") + val result = executeOperation(operation, client) + handleResult(operation, totalOperationSize, index, result) + } catch (e: Exception) { + Log_OC.e(TAG, "💥 Exception while processing operation id=${operation.id}: ${e.message}") + } + } + } + + private fun handleResult( + operation: OfflineOperationEntity, + totalOperations: Int, + currentSuccessfulOperationIndex: Int, + result: OfflineOperationResult + ) { + val operationResult = result?.first ?: return + val logMessage = if (operationResult.isSuccess) "Operation completed" else "Operation failed" + Log_OC.d(TAG, "$logMessage filename: ${operation.filename}, type: ${operation.type}") + + return if (result.first?.isSuccess == true) { + handleSuccessResult(operation, totalOperations, currentSuccessfulOperationIndex) + } else { + handleErrorResult(operation.id, result) + } + } + + private fun handleSuccessResult( + operation: OfflineOperationEntity, + totalOperations: Int, + currentSuccessfulOperationIndex: Int + ) { + if (operation.type is OfflineOperationType.RemoveFile) { + val operationType = operation.type as OfflineOperationType.RemoveFile + fileDataStorageManager.getFileByDecryptedRemotePath(operationType.path)?.let { ocFile -> + repository.deleteOperation(ocFile) + } + } else { + repository.updateNextOperations(operation) + } + + fileDataStorageManager.offlineOperationDao.delete(operation) + notificationManager.update(totalOperations, currentSuccessfulOperationIndex + 1, operation.filename ?: "") + } + + private fun handleErrorResult(id: Int?, result: OfflineOperationResult) { + val operationResult = result?.first ?: return + val operation = result.second ?: return + Log_OC.e(TAG, "❌ Operation failed [id=$id]: code=${operationResult.code}, message=${operationResult.message}") + val excludedErrorCodes = + listOf(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS, RemoteOperationResult.ResultCode.LOCKED) + + if (!excludedErrorCodes.contains(operationResult.code)) { + notificationManager.showNewNotification(id, operationResult, operation) + } else { + Log_OC.d(TAG, "ℹ️ Ignored error: ${operationResult.code}") + } + } + // endregion + + private suspend fun isNetworkAndServerAvailable(): Boolean = suspendCoroutine { continuation -> + connectivityService.isNetworkAndServerAvailable { result -> + continuation.resume(result) + } + } + + // region Operation Execution + @Suppress("ComplexCondition", "LongMethod") + private suspend fun executeOperation( + operation: OfflineOperationEntity, + client: OwnCloudClient + ): OfflineOperationResult? = withContext(Dispatchers.IO) { + var path = (operation.path) + if (path == null) { + Log_OC.w(TAG, "⚠️ Skipped: path is null for operation id=${operation.id}") + return@withContext null + } + + if (operation.type is OfflineOperationType.CreateFile && path.endsWith(OCFile.PATH_SEPARATOR)) { + Log_OC.w( + TAG, + "Create file operation should not ends with path separator removing suffix, " + + "operation id=${operation.id}" + ) + path = path.removeSuffix(OCFile.PATH_SEPARATOR) + } + + val remoteFile = getRemoteFile(path) + val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(path) + + if (remoteFile != null && ocFile != null && isFileChanged(remoteFile, ocFile)) { + Log_OC.w(TAG, "⚠️ Conflict detected: File already exists on server. Skipping operation id=${operation.id}") + + if (operation.isRenameOrRemove()) { + Log_OC.d(TAG, "🗑 Removing conflicting rename/remove operation id=${operation.id}") + fileDataStorageManager.offlineOperationDao.delete(operation) + notificationManager.showConflictNotificationForDeleteOrRemoveOperation(operation) + } else { + Log_OC.d(TAG, "📌 Showing conflict resolution for operation id=${operation.id}") + notificationManager.showConflictResolveNotification(ocFile, operation) + } + + return@withContext null + } + + if (operation.isRenameOrRemove() && ocFile == null) { + Log_OC.d(TAG, "Skipping, attempting to delete or rename non-existing file") + fileDataStorageManager.offlineOperationDao.delete(operation) + return@withContext null + } + + if (operation.isCreate() && remoteFile != null && ocFile != null && !isFileChanged(remoteFile, ocFile)) { + Log_OC.d(TAG, "Skipping, attempting to create same file creation") + fileDataStorageManager.offlineOperationDao.delete(operation) + return@withContext null + } + + return@withContext when (val type = operation.type) { + is OfflineOperationType.CreateFolder -> { + Log_OC.d(TAG, "📂 Creating folder at ${type.path}") + createFolder(operation, client) + } + + is OfflineOperationType.CreateFile -> { + Log_OC.d(TAG, "📤 Uploading file: local=${type.localPath} → remote=${type.remotePath}") + createFile(operation, client) + } + + is OfflineOperationType.RenameFile -> { + Log_OC.d(TAG, "✏️ Renaming ${operation.path} → ${type.newName}") + renameFile(operation, client) + } + + is OfflineOperationType.RemoveFile -> { + Log_OC.d(TAG, "🗑 Removing file: ${operation.path}") + ocFile?.let { removeFile(it, client) } + } + + else -> { + Log_OC.d(TAG, "⚠️ Unsupported operation type: $type") + null + } + } + } + + @Suppress("DEPRECATION") + private fun createFolder(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult { + val operationType = (operation.type as OfflineOperationType.CreateFolder) + val createFolderOperation = CreateFolderOperation(operationType.path, user, context, fileDataStorageManager) + return createFolderOperation.execute(client) to createFolderOperation + } + + @Suppress("DEPRECATION") + private fun createFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult { + val operationType = (operation.type as OfflineOperationType.CreateFile) + val lastModificationDate = System.currentTimeMillis() / ONE_SECOND + val createFileOperation = UploadFileRemoteOperation( + operationType.localPath, + operationType.remotePath, + operationType.mimeType, + "", + operation.modifiedAt ?: lastModificationDate, + operation.createdAt ?: System.currentTimeMillis(), + true + ) + return createFileOperation.execute(client) to createFileOperation + } + + @Suppress("DEPRECATION") + private fun renameFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult { + val operationType = (operation.type as OfflineOperationType.RenameFile) + val renameFileOperation = RenameFileOperation(operation.path, operationType.newName, fileDataStorageManager) + return renameFileOperation.execute(client) to renameFileOperation + } + + @Suppress("DEPRECATION") + private fun removeFile(ocFile: OCFile, client: OwnCloudClient): OfflineOperationResult { + val removeFileOperation = RemoveFileOperation(ocFile, false, user, true, context, fileDataStorageManager) + return removeFileOperation.execute(client) to removeFileOperation + } + // endregion + + @Suppress("DEPRECATION") + private fun getRemoteFile(remotePath: String): RemoteFile? { + val mimeType = MimeTypeUtil.getMimeTypeFromPath(remotePath) + val isFolder = MimeTypeUtil.isFolder(mimeType) + val client = ClientFactoryImpl(context).create(user) + val result = if (isFolder) { + ReadFolderRemoteOperation(remotePath).execute(client) + } else { + ReadFileRemoteOperation(remotePath).execute(client) + } + + return if (result.isSuccess) { + result.data[0] as? RemoteFile + } else { + null + } + } + + private fun isFileChanged(remoteFile: RemoteFile, ocFile: OCFile): Boolean = remoteFile.etag != ocFile.etagOnServer +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/receiver/OfflineOperationReceiver.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/receiver/OfflineOperationReceiver.kt new file mode 100644 index 000000000000..54179edeaa32 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/receiver/OfflineOperationReceiver.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.offlineOperations.receiver + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsNotificationManager +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import javax.inject.Inject + +class OfflineOperationReceiver : BroadcastReceiver() { + companion object { + const val ID = "id" + } + + @Inject + lateinit var storageManager: FileDataStorageManager + + override fun onReceive(context: Context, intent: Intent) { + MainApp.getAppComponent().inject(this) + + val id = intent.getIntExtra(ID, -1) + if (id == -1) { + return + } + + storageManager.offlineOperationDao.deleteById(id) + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel( + OfflineOperationsNotificationManager.ERROR_ID + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt new file mode 100644 index 000000000000..86170e455de3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt @@ -0,0 +1,114 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.offlineOperations.repository + +import com.nextcloud.client.database.entity.OfflineOperationEntity +import com.nextcloud.model.OfflineOperationType +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.MimeType +import com.owncloud.android.utils.MimeTypeUtil + +class OfflineOperationsRepository(private val fileDataStorageManager: FileDataStorageManager) : + OfflineOperationsRepositoryType { + + private val dao = fileDataStorageManager.offlineOperationDao + private val pathSeparator = '/' + + @Suppress("NestedBlockDepth") + override fun getAllSubEntities(fileId: Long): List { + val result = mutableListOf() + val queue = ArrayDeque() + queue.add(fileId) + val processedIds = mutableSetOf() + + while (queue.isNotEmpty()) { + val currentFileId = queue.removeFirst() + if (currentFileId in processedIds || currentFileId == 1L) continue + + processedIds.add(currentFileId) + + val subDirectories = dao.getSubEntitiesByParentOCFileId(currentFileId) + result.addAll(subDirectories) + + subDirectories.forEach { + val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(it.path) + ocFile?.fileId?.let { newFileId -> + if (newFileId != 1L && newFileId !in processedIds) { + queue.add(newFileId) + } + } + } + } + + return result + } + + override fun deleteOperation(file: OCFile) { + if (file.isFolder) { + getAllSubEntities(file.fileId).forEach { + dao.delete(it) + } + } + + file.decryptedRemotePath?.let { + dao.deleteByPath(it) + } + + fileDataStorageManager.removeFile(file, true, true) + } + + override fun updateNextOperations(operation: OfflineOperationEntity) { + val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(operation.path) + val fileId = ocFile?.fileId ?: return + + getAllSubEntities(fileId) + .mapNotNull { nextOperation -> + nextOperation.parentOCFileId?.let { parentId -> + fileDataStorageManager.getFileById(parentId)?.let { ocFile -> + ocFile.decryptedRemotePath?.let { updatedPath -> + val newPath = updatedPath + nextOperation.filename + pathSeparator + + if (newPath != nextOperation.path) { + nextOperation.apply { + type = when (type) { + is OfflineOperationType.CreateFile -> + (type as OfflineOperationType.CreateFile).copy( + remotePath = newPath + ) + + is OfflineOperationType.CreateFolder -> + (type as OfflineOperationType.CreateFolder).copy( + path = newPath + ) + + else -> type + } + path = newPath + } + } else { + null + } + } + } + } + } + .forEach { dao.update(it) } + } + + override fun convertToOCFiles(fileId: Long): List = + dao.getSubEntitiesByParentOCFileId(fileId).map { entity -> + OCFile(entity.path).apply { + mimeType = if (entity.type is OfflineOperationType.CreateFolder) { + MimeType.DIRECTORY + } else { + MimeTypeUtil.getMimeTypeFromPath(entity.path) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt new file mode 100644 index 000000000000..b6509093fac9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.offlineOperations.repository + +import com.nextcloud.client.database.entity.OfflineOperationEntity +import com.owncloud.android.datamodel.OCFile + +interface OfflineOperationsRepositoryType { + fun getAllSubEntities(fileId: Long): List + fun deleteOperation(file: OCFile) + fun updateNextOperations(operation: OfflineOperationEntity) + fun convertToOCFiles(fileId: Long): List +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/operation/FileOperationHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/operation/FileOperationHelper.kt new file mode 100644 index 000000000000..6cbcb7766893 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/operation/FileOperationHelper.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.operation + +import android.content.Context +import com.nextcloud.client.account.User +import com.nextcloud.utils.extensions.getErrorMessage +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.RemoveFileOperation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext + +class FileOperationHelper( + private val user: User, + private val context: Context, + private val fileDataStorageManager: FileDataStorageManager +) { + companion object { + private val TAG = FileOperationHelper::class.java.simpleName + } + + @Suppress("TooGenericExceptionCaught", "Deprecation") + suspend fun removeFile( + file: OCFile, + onlyLocalCopy: Boolean, + inBackground: Boolean, + client: OwnCloudClient + ): Boolean { + return withContext(Dispatchers.IO) { + try { + val operation = async { + RemoveFileOperation( + file, + onlyLocalCopy, + user, + inBackground, + context, + fileDataStorageManager + ) + } + val operationResult = operation.await() + val result = operationResult.execute(client) + + return@withContext if (result.isSuccess) { + true + } else { + val reason = (result to operationResult).getErrorMessage() + Log_OC.e(TAG, "Error occurred while removing file: $reason") + false + } + } catch (e: Exception) { + Log_OC.e(TAG, "Error occurred while removing file: $e") + false + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/transfer/FileTransferService.kt b/app/src/main/java/com/nextcloud/client/jobs/transfer/FileTransferService.kt new file mode 100644 index 000000000000..cfe0d0d80d81 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/transfer/FileTransferService.kt @@ -0,0 +1,180 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.transfer + +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleService +import com.nextcloud.client.account.User +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.core.LocalBinder +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.files.Direction +import com.nextcloud.client.files.Request +import com.nextcloud.client.jobs.download.DownloadTask +import com.nextcloud.client.jobs.upload.UploadTask +import com.nextcloud.client.logger.Logger +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.notifications.AppNotificationManager +import com.nextcloud.utils.ForegroundServiceHelper +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.ForegroundServiceType +import com.owncloud.android.datamodel.UploadsStorageManager +import dagger.android.AndroidInjection +import javax.inject.Inject +import javax.inject.Named + +class FileTransferService : LifecycleService() { + + companion object { + const val TAG = "DownloaderService" + const val ACTION_TRANSFER = "transfer" + const val EXTRA_REQUEST = "request" + const val EXTRA_USER = "user" + + fun createBindIntent(context: Context, user: User): Intent = + Intent(context, FileTransferService::class.java).apply { + putExtra(EXTRA_USER, user) + } + + fun createTransferRequestIntent(context: Context, request: Request): Intent = + Intent(context, FileTransferService::class.java).apply { + action = ACTION_TRANSFER + putExtra(EXTRA_REQUEST, request) + } + } + + /** + * Binder forwards [TransferManager] API calls to selected instance of downloader. + */ + class Binder(downloader: TransferManagerImpl, service: FileTransferService) : + LocalBinder(service), + TransferManager by downloader + + @Inject + lateinit var notificationsManager: AppNotificationManager + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + @Named("io") + lateinit var runner: AsyncRunner + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var uploadsStorageManager: UploadsStorageManager + + @Inject + lateinit var connectivityService: ConnectivityService + + @Inject + lateinit var powerManagementService: PowerManagementService + + @Inject + lateinit var fileDataStorageManager: FileDataStorageManager + + val isRunning: Boolean get() = downloaders.any { it.value.isRunning } + + private val downloaders: MutableMap = mutableMapOf() + + override fun onCreate() { + AndroidInjection.inject(this) + super.onCreate() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + if (intent == null || intent.action != ACTION_TRANSFER) { + return START_NOT_STICKY + } + + if (!isRunning && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + ForegroundServiceHelper.startService( + this, + AppNotificationManager.TRANSFER_NOTIFICATION_ID, + notificationsManager.buildDownloadServiceForegroundNotification(), + ForegroundServiceType.DataSync + ) + } + + val request: Request = intent.getParcelableArgument(EXTRA_REQUEST, Request::class.java)!! + + getTransferManager(request.user).run { + enqueue(request) + } + + logger.d(TAG, "Enqueued new transfer: ${request.uuid} ${request.file.remotePath}") + + return START_NOT_STICKY + } + + override fun onBind(intent: Intent): IBinder? { + super.onBind(intent) + val user = intent.getParcelableArgument(EXTRA_USER, User::class.java) ?: return null + return Binder(getTransferManager(user), this) + } + + private fun onTransferUpdate(transfer: Transfer) { + if (!isRunning) { + logger.d(TAG, "All downloads completed") + notificationsManager.cancelTransferNotification() + stopForeground(STOP_FOREGROUND_DETACH) + stopSelf() + } else if (transfer.direction == Direction.DOWNLOAD) { + notificationsManager.postDownloadTransferProgress( + fileOwner = transfer.request.user, + file = transfer.request.file, + progress = transfer.progress, + allowPreview = !transfer.request.test + ) + } else if (transfer.direction == Direction.UPLOAD) { + notificationsManager.postUploadTransferProgress( + fileOwner = transfer.request.user, + file = transfer.request.file, + progress = transfer.progress + ) + } + } + + override fun onDestroy() { + super.onDestroy() + logger.d(TAG, "Stopping downloader service") + } + + private fun getTransferManager(user: User): TransferManagerImpl { + val existingDownloader = downloaders[user.accountName] + return if (existingDownloader != null) { + existingDownloader + } else { + val downloadTaskFactory = DownloadTask.Factory( + applicationContext, + { clientFactory.create(user) }, + contentResolver + ) + val uploadTaskFactory = UploadTask.Factory( + applicationContext, + uploadsStorageManager, + connectivityService, + powerManagementService, + { clientFactory.create(user) }, + fileDataStorageManager + ) + val newDownloader = TransferManagerImpl(runner, downloadTaskFactory, uploadTaskFactory) + newDownloader.registerTransferListener(this::onTransferUpdate) + downloaders[user.accountName] = newDownloader + newDownloader + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/transfer/Transfer.kt b/app/src/main/java/com/nextcloud/client/jobs/transfer/Transfer.kt new file mode 100644 index 000000000000..b3ecdae4b0b6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/transfer/Transfer.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.transfer + +import com.nextcloud.client.files.Direction +import com.nextcloud.client.files.DownloadRequest +import com.nextcloud.client.files.Request +import com.nextcloud.client.files.UploadRequest +import com.owncloud.android.datamodel.OCFile +import java.util.UUID + +/** + * This class represents current transfer (download or upload) process state. + * This object is immutable by design. + * + * NOTE: Although [OCFile] object is mutable, it is caused by shortcomings + * of legacy design; please behave like an adult and treat it as immutable value. + * + * @property uuid Unique transfer id + * @property state current transfer state + * @property progress transfer progress, 0-100 percent + * @property file transferred file + * @property request initial transfer request + * @property direction transfer direction, download or upload + */ +data class Transfer( + val uuid: UUID, + val state: TransferState, + val progress: Int, + val file: OCFile, + val request: Request +) { + /** + * True if download is no longer running, false if it is still being processed. + */ + val isFinished: Boolean get() = state == TransferState.COMPLETED || state == TransferState.FAILED + + val direction: Direction + get() = when (request) { + is DownloadRequest -> Direction.DOWNLOAD + is UploadRequest -> Direction.UPLOAD + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManager.kt b/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManager.kt new file mode 100644 index 000000000000..ccb688d97408 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManager.kt @@ -0,0 +1,86 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.transfer + +import com.nextcloud.client.files.Request +import com.owncloud.android.datamodel.OCFile +import java.util.UUID + +/** + * Transfer manager provides API to upload and download files. + */ +interface TransferManager { + + /** + * Snapshot of transfer manager status. All data is immutable and can be safely shared. + */ + data class Status(val pending: List, val running: List, val completed: List) { + companion object { + val EMPTY = Status(emptyList(), emptyList(), emptyList()) + } + } + + /** + * True if transfer manager has any pending or running transfers. + */ + val isRunning: Boolean + + /** + * Status snapshot of all transfers. + */ + val status: Status + + /** + * Register transfer progress listener. Registration is idempotent - a listener will be registered only once. + */ + fun registerTransferListener(listener: (Transfer) -> Unit) + + /** + * Removes registered listener if exists. + */ + fun removeTransferListener(listener: (Transfer) -> Unit) + + /** + * Register transfer manager status listener. Registration is idempotent - a listener will be registered only once. + */ + fun registerStatusListener(listener: (Status) -> Unit) + + /** + * Removes registered listener if exists. + */ + fun removeStatusListener(listener: (Status) -> Unit) + + /** + * Adds transfer request to pending queue and returns immediately. + * + * @param request Transfer request + */ + fun enqueue(request: Request) + + /** + * Find transfer status by UUID. + * + * @param uuid Download process uuid + * @return transfer status or null if not found + */ + fun getTransfer(uuid: UUID): Transfer? + + /** + * Query user's transfer manager for a transfer status. It performs linear search + * of all queues and returns first transfer matching [OCFile.remotePath]. + * + * Since there can be multiple transfers with identical file in the queues, + * order of search matters. + * + * It looks for pending transfers first, then running and completed queue last. + * + * @param file Downloaded file + * @return transfer status or null, if transfer does not exist + */ + fun getTransfer(file: OCFile): Transfer? +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManagerConnection.kt b/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManagerConnection.kt new file mode 100644 index 000000000000..d5d97c3f1923 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManagerConnection.kt @@ -0,0 +1,111 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.transfer + +import android.content.Context +import android.content.Intent +import android.os.IBinder +import com.nextcloud.client.account.User +import com.nextcloud.client.core.LocalConnection +import com.nextcloud.client.files.Request +import com.owncloud.android.datamodel.OCFile +import java.util.UUID + +class TransferManagerConnection(context: Context, val user: User) : + LocalConnection(context), + TransferManager { + + private var transferListeners: MutableSet<(Transfer) -> Unit> = mutableSetOf() + private var statusListeners: MutableSet<(TransferManager.Status) -> Unit> = mutableSetOf() + private var binder: FileTransferService.Binder? = null + private val transfersRequiringStatusRedelivery: MutableSet = mutableSetOf() + + override val isRunning: Boolean + get() = binder?.isRunning ?: false + + override val status: TransferManager.Status + get() = binder?.status ?: TransferManager.Status.EMPTY + + override fun getTransfer(uuid: UUID): Transfer? = binder?.getTransfer(uuid) + + override fun getTransfer(file: OCFile): Transfer? = binder?.getTransfer(file) + + override fun enqueue(request: Request) { + val intent = FileTransferService.createTransferRequestIntent(context, request) + context.startService(intent) + if (!isConnected && transferListeners.size > 0) { + transfersRequiringStatusRedelivery.add(request.uuid) + } + } + + override fun registerTransferListener(listener: (Transfer) -> Unit) { + transferListeners.add(listener) + binder?.registerTransferListener(listener) + } + + override fun removeTransferListener(listener: (Transfer) -> Unit) { + transferListeners.remove(listener) + binder?.removeTransferListener(listener) + } + + override fun registerStatusListener(listener: (TransferManager.Status) -> Unit) { + statusListeners.add(listener) + binder?.registerStatusListener(listener) + } + + override fun removeStatusListener(listener: (TransferManager.Status) -> Unit) { + statusListeners.remove(listener) + binder?.removeStatusListener(listener) + } + + override fun createBindIntent(): Intent = FileTransferService.createBindIntent(context, user) + + override fun onBound(binder: IBinder) { + super.onBound(binder) + this.binder = binder as FileTransferService.Binder + transferListeners.forEach { listener -> + binder.registerTransferListener(listener) + } + statusListeners.forEach { listener -> + binder.registerStatusListener(listener) + } + deliverMissedUpdates() + } + + /** + * Since binding and transfer start are both asynchronous and the order + * is not guaranteed, some transfers might already finish when service is bound, + * resulting in lost notifications. + * + * Deliver all updates for pending transfers that were scheduled + * before service was bound. + */ + private fun deliverMissedUpdates() { + val transferUpdates = transfersRequiringStatusRedelivery.mapNotNull { uuid -> + binder?.getTransfer(uuid) + } + transferListeners.forEach { listener -> + transferUpdates.forEach { update -> + listener.invoke(update) + } + } + transfersRequiringStatusRedelivery.clear() + + if (statusListeners.isNotEmpty()) { + binder?.status?.let { status -> + statusListeners.forEach { it.invoke(status) } + } + } + } + + override fun onUnbind() { + super.onUnbind() + transferListeners.forEach { binder?.removeTransferListener(it) } + statusListeners.forEach { binder?.removeStatusListener(it) } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManagerImpl.kt new file mode 100644 index 000000000000..45008febe3cd --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferManagerImpl.kt @@ -0,0 +1,196 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.transfer + +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.core.IsCancelled +import com.nextcloud.client.core.OnProgressCallback +import com.nextcloud.client.core.TaskFunction +import com.nextcloud.client.files.DownloadRequest +import com.nextcloud.client.files.Registry +import com.nextcloud.client.files.Request +import com.nextcloud.client.files.UploadRequest +import com.nextcloud.client.jobs.download.DownloadTask +import com.nextcloud.client.jobs.upload.UploadTask +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.operations.UploadFileOperation +import java.util.UUID + +/** + * Per-user file transfer manager. + * + * All notifications are performed on main thread. All transfer processes are run + * in the background. + * + * @param runner Background task runner. It is important to provide runner that is not shared with UI code. + * @param downloadTaskFactory Download task factory + * @param threads maximum number of concurrent transfer processes + */ +@Suppress("LongParameterList") // transfer operations requires those resources +class TransferManagerImpl( + private val runner: AsyncRunner, + private val downloadTaskFactory: DownloadTask.Factory, + private val uploadTaskFactory: UploadTask.Factory, + threads: Int = 1 +) : TransferManager { + + companion object { + const val PROGRESS_PERCENTAGE_MAX = 100 + const val PROGRESS_PERCENTAGE_MIN = 0 + const val TEST_DOWNLOAD_PROGRESS_UPDATE_PERIOD_MS = 200L + const val TEST_UPLOAD_PROGRESS_UPDATE_PERIOD_MS = 200L + } + + private val registry = Registry( + onStartTransfer = this::onStartTransfer, + onTransferChanged = this::onTransferUpdate, + maxRunning = threads + ) + private val transferListeners: MutableSet<(Transfer) -> Unit> = mutableSetOf() + private val statusListeners: MutableSet<(TransferManager.Status) -> Unit> = mutableSetOf() + + override val isRunning: Boolean get() = registry.isRunning + + override val status: TransferManager.Status + get() = TransferManager.Status( + pending = registry.pending, + running = registry.running, + completed = registry.completed + ) + + override fun registerTransferListener(listener: (Transfer) -> Unit) { + transferListeners.add(listener) + } + + override fun removeTransferListener(listener: (Transfer) -> Unit) { + transferListeners.remove(listener) + } + + override fun registerStatusListener(listener: (TransferManager.Status) -> Unit) { + statusListeners.add(listener) + } + + override fun removeStatusListener(listener: (TransferManager.Status) -> Unit) { + statusListeners.remove(listener) + } + + override fun enqueue(request: Request) { + registry.add(request) + registry.startNext() + } + + override fun getTransfer(uuid: UUID): Transfer? = registry.getTransfer(uuid) + + override fun getTransfer(file: OCFile): Transfer? = registry.getTransfer(file) + + private fun onStartTransfer(uuid: UUID, request: Request) { + if (request is DownloadRequest) { + runner.postTask( + task = createDownloadTask(request), + onProgress = { progress: Int -> registry.progress(uuid, progress) }, + onResult = { result -> + registry.complete(uuid, result.success, result.file) + registry.startNext() + }, + onError = { + registry.complete(uuid, false) + registry.startNext() + } + ) + } else if (request is UploadRequest) { + runner.postTask( + task = createUploadTask(request), + onProgress = { progress: Int -> registry.progress(uuid, progress) }, + onResult = { result -> + registry.complete(uuid, result.success, result.file) + registry.startNext() + }, + onError = { + registry.complete(uuid, false) + registry.startNext() + } + ) + } + } + + private fun createDownloadTask(request: DownloadRequest): TaskFunction = + if (request.test) { + { progress: OnProgressCallback, isCancelled: IsCancelled -> + testDownloadTask(request.file, progress, isCancelled) + } + } else { + val downloadTask = downloadTaskFactory.create() + val wrapper: TaskFunction = { progress: ((Int) -> Unit), isCancelled -> + downloadTask.download(request, progress, isCancelled) + } + wrapper + } + + private fun createUploadTask(request: UploadRequest): TaskFunction = if (request.test) { + { progress: OnProgressCallback, isCancelled: IsCancelled -> + val file = UploadFileOperation.obtainNewOCFileToUpload( + request.upload.remotePath, + request.upload.localPath, + request.upload.mimeType + ) + testUploadTask(file, progress, isCancelled) + } + } else { + val uploadTask = uploadTaskFactory.create() + val wrapper: TaskFunction = { _: ((Int) -> Unit), _ -> + uploadTask.upload(request.user, request.upload) + } + wrapper + } + + private fun onTransferUpdate(transfer: Transfer) { + transferListeners.forEach { it.invoke(transfer) } + if (statusListeners.isNotEmpty()) { + val status = this.status + statusListeners.forEach { it.invoke(status) } + } + } + + /** + * Test download task is used only to simulate download process without + * any network traffic. It is used for development. + */ + private fun testDownloadTask( + file: OCFile, + onProgress: OnProgressCallback, + isCancelled: IsCancelled + ): DownloadTask.Result { + for (i in PROGRESS_PERCENTAGE_MIN..PROGRESS_PERCENTAGE_MAX) { + onProgress(i) + if (isCancelled()) { + return DownloadTask.Result(file, false) + } + Thread.sleep(TEST_DOWNLOAD_PROGRESS_UPDATE_PERIOD_MS) + } + return DownloadTask.Result(file, true) + } + + /** + * Test upload task is used only to simulate upload process without + * any network traffic. It is used for development. + */ + private fun testUploadTask( + file: OCFile, + onProgress: OnProgressCallback, + isCancelled: IsCancelled + ): UploadTask.Result { + for (i in PROGRESS_PERCENTAGE_MIN..PROGRESS_PERCENTAGE_MAX) { + onProgress(i) + if (isCancelled()) { + return UploadTask.Result(file, false) + } + Thread.sleep(TEST_UPLOAD_PROGRESS_UPDATE_PERIOD_MS) + } + return UploadTask.Result(file, true) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferState.kt b/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferState.kt new file mode 100644 index 000000000000..5f6b39cece4f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/transfer/TransferState.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.transfer + +enum class TransferState { + PENDING, + RUNNING, + COMPLETED, + FAILED +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadBroadcastReceiver.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadBroadcastReceiver.kt new file mode 100644 index 000000000000..0e5fcf90e489 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadBroadcastReceiver.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.upload + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.UploadsStorageManager +import javax.inject.Inject + +class FileUploadBroadcastReceiver : BroadcastReceiver() { + + @Inject + lateinit var uploadsStorageManager: UploadsStorageManager + + companion object { + // region cancel or remove actions + const val UPLOAD_ID = "UPLOAD_ID" + const val ACCOUNT_NAME = "ACCOUNT_NAME" + const val REMOTE_PATH = "REMOTE_PATH" + const val REMOVE = "REMOVE" + // endregion + } + + @Suppress("ReturnCount") + override fun onReceive(context: Context, intent: Intent) { + MainApp.getAppComponent().inject(this) + + if (intent.action == UploadBroadcastAction.PauseAndCancel::class.simpleName) { + pauseAndCancel(context, intent) + } + } + + @Suppress("ReturnCount") + private fun pauseAndCancel(context: Context, intent: Intent) { + val uploadId = intent.getLongExtra(UPLOAD_ID, -1L) + if (uploadId == -1L) { + return + } + + val accountName = intent.getStringExtra(ACCOUNT_NAME) + if (accountName.isNullOrEmpty()) { + return + } + + val remotePath = intent.getStringExtra(REMOTE_PATH) + if (remotePath.isNullOrEmpty()) { + return + } + + val remove = intent.getBooleanExtra(REMOVE, false) + + FileUploadWorker.cancelUpload(remotePath, accountName) + + if (remove) { + uploadsStorageManager.removeUpload(uploadId) + } else { + FileUploadHelper.instance().updateUploadStatus( + remotePath, + accountName, + UploadsStorageManager.UploadStatus.UPLOAD_CANCELLED + ) + } + + // dismiss notification + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(uploadId.toInt()) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadEventBroadcaster.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadEventBroadcaster.kt new file mode 100644 index 000000000000..5ddd61f20f09 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadEventBroadcaster.kt @@ -0,0 +1,114 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import android.content.Context +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.UploadFileOperation + +/** + * Manages local broadcasts related to file upload lifecycle events. + * + * This class is responsible for notifying components about upload + * queue changes and upload state transitions (added, started, finished). + * + * All broadcasts are sent via [LocalBroadcastManager]. + */ +class FileUploadEventBroadcaster(private val broadcastManager: LocalBroadcastManager) { + + companion object { + private const val TAG = "📣" + "FileUploadBroadcastManager" + + private const val PREFIX = "com.nextcloud.client.upload." + + const val ACTION_UPLOAD_ENQUEUED = PREFIX + "ACTION_UPLOAD_ENQUEUED" + const val ACTION_UPLOAD_STARTED = PREFIX + "ACTION_UPLOAD_STARTED" + const val ACTION_UPLOAD_COMPLETED = PREFIX + "UPLOAD_FINISHED" + + const val EXTRA_REMOTE_PATH = PREFIX + "EXTRA_REMOTE_PATH" + const val EXTRA_OLD_FILE_PATH = PREFIX + "EXTRA_OLD_FILE_PATH" + const val EXTRA_ACCOUNT_NAME = PREFIX + "EXTRA_ACCOUNT_NAME" + const val EXTRA_OLD_REMOTE_PATH = PREFIX + "EXTRA_OLD_REMOTE_PATH" + const val EXTRA_UPLOAD_RESULT = PREFIX + "EXTRA_UPLOAD_RESULT" + } + + /** + * Sends a broadcast to indicate that an upload has been added to the database. + * + * ### Triggered when + * - [UploadFileOperation] added + * + * ### Observed by + * - [com.owncloud.android.ui.activity.UploadListActivity.UploadFinishReceiver] + * + */ + fun sendUploadEnqueued(context: Context) { + Log_OC.d(TAG, "Upload enqueued broadcast sent") + + val intent = Intent(ACTION_UPLOAD_ENQUEUED).apply { + setPackage(context.packageName) + } + broadcastManager.sendBroadcast(intent) + } + + /** + * Sends a broadcast indicating that an upload started. + * + * ### Triggered when + * - [UploadFileOperation] started + * + * ### Observed by + * - [com.owncloud.android.ui.activity.UploadListActivity.UploadFinishReceiver] + * + */ + fun sendUploadStarted(upload: UploadFileOperation, context: Context) { + Log_OC.d(TAG, "Upload started broadcast sent") + + val intent = Intent(ACTION_UPLOAD_STARTED).apply { + putExtra(EXTRA_REMOTE_PATH, upload.remotePath) // real remote + putExtra(EXTRA_OLD_FILE_PATH, upload.originalStoragePath) + putExtra(EXTRA_ACCOUNT_NAME, upload.user.accountName) + setPackage(context.packageName) + } + broadcastManager.sendBroadcast(intent) + } + + /** + * Sends a broadcast indicating that an upload has finished, either + * successfully or with an error. + * + * ### Triggered when + * - [UploadFileOperation] completes execution + * + * ### Observed by + * - [com.owncloud.android.ui.activity.FileDisplayActivity.FileUploadCompletedReceiver] + * - [com.owncloud.android.ui.activity.UploadListActivity.UploadFinishReceiver] + * + */ + fun sendUploadCompleted(upload: UploadFileOperation, uploadResult: RemoteOperationResult<*>, context: Context) { + Log_OC.d(TAG, "Upload completed broadcast sent") + + val intent = Intent(ACTION_UPLOAD_COMPLETED).apply { + // real remote path, after possible automatic renaming + putExtra(EXTRA_REMOTE_PATH, upload.remotePath) + if (upload.wasRenamed()) { + upload.oldFile?.let { + putExtra(EXTRA_OLD_REMOTE_PATH, it.remotePath) + } + } + putExtra(EXTRA_OLD_FILE_PATH, upload.originalStoragePath) + putExtra(EXTRA_ACCOUNT_NAME, upload.user.accountName) + putExtra(EXTRA_UPLOAD_RESULT, uploadResult.isSuccess) + setPackage(context.packageName) + } + broadcastManager.sendBroadcast(intent) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt new file mode 100644 index 000000000000..b4dea7a2b062 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -0,0 +1,680 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.database.entity.SyncedFolderEntity +import com.nextcloud.client.database.entity.UploadEntity +import com.nextcloud.client.database.entity.toOCUpload +import com.nextcloud.client.database.entity.toUploadEntity +import com.nextcloud.client.device.BatteryStatus +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.network.Connectivity +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.notifications.AppWideNotificationManager +import com.nextcloud.utils.extensions.checkWCFRestrictions +import com.nextcloud.utils.extensions.getUploadIds +import com.nextcloud.utils.extensions.isAnonymous +import com.nextcloud.utils.extensions.isLastResultConflictError +import com.nextcloud.utils.extensions.isSame +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.lib.resources.files.model.ServerFileInterface +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.operations.RemoveFileOperation +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.Semaphore +import javax.inject.Inject + +@Suppress("TooManyFunctions") +class FileUploadHelper { + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + @Inject + lateinit var accountManager: UserAccountManager + + @Inject + lateinit var uploadsStorageManager: UploadsStorageManager + + @Inject + lateinit var fileStorageManager: FileDataStorageManager + + private val ioScope = CoroutineScope(Dispatchers.IO) + + init { + MainApp.getAppComponent().inject(this) + } + + companion object { + private val TAG = FileUploadWorker::class.java.simpleName + + const val MAX_FILE_COUNT = 500 + + val mBoundListeners = HashMap() + + private var instance: FileUploadHelper? = null + + private val retryFailedUploadsSemaphore = Semaphore(1) + + fun instance(): FileUploadHelper = instance ?: synchronized(this) { + instance ?: FileUploadHelper().also { instance = it } + } + + fun buildRemoteName(accountName: String, remotePath: String): String = accountName + remotePath + } + + /** + * Retries all failed uploads across all user accounts. + * + * This function retrieves all uploads with the status [UploadStatus.UPLOAD_FAILED], including both + * manual uploads and auto uploads. It runs in a background thread (Dispatcher.IO) and ensures + * that only one retry operation runs at a time by using a semaphore to prevent concurrent execution. + * + * Once the failed uploads are retrieved, it calls [retryUploads], which triggers the corresponding + * upload workers for each failed upload. + * + * The function returns `true` if there were any failed uploads to retry and the retry process was + * started, or `false` if no uploads were retried. + * + * @param uploadsStorageManager Provides access to upload data and persistence. + * @param connectivityService Checks the current network connectivity state. + * @param accountManager Handles user account authentication and selection. + * @param powerManagementService Ensures uploads respect power constraints. + * @return `true` if any failed uploads were found and retried; `false` otherwise. + */ + fun retryFailedUploads( + uploadsStorageManager: UploadsStorageManager, + connectivityService: ConnectivityService, + accountManager: UserAccountManager, + powerManagementService: PowerManagementService + ): Boolean { + if (!retryFailedUploadsSemaphore.tryAcquire()) { + Log_OC.d(TAG, "skipping retryFailedUploads, already running") + return true + } + + var isUploadStarted = false + val capability = fileStorageManager.getCapability(accountManager.user) + + try { + ioScope.launch { + val uploads = getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED, capability) + if (uploads.isNotEmpty()) { + isUploadStarted = true + } + + retryUploads( + uploadsStorageManager, + connectivityService, + accountManager, + powerManagementService, + uploads + ) + } + } finally { + retryFailedUploadsSemaphore.release() + } + + return isUploadStarted + } + + suspend fun retryCancelledUploads( + uploadsStorageManager: UploadsStorageManager, + connectivityService: ConnectivityService, + accountManager: UserAccountManager, + powerManagementService: PowerManagementService + ): Boolean { + val capability = fileStorageManager.getCapability(accountManager.user) + val uploads = getUploadsByStatus(accountManager.user.accountName, UploadStatus.UPLOAD_CANCELLED, capability) + return retryUploads( + uploadsStorageManager, + connectivityService, + accountManager, + powerManagementService, + uploads + ) + } + + @Suppress("ComplexCondition") + private suspend fun retryUploads( + uploadsStorageManager: UploadsStorageManager, + connectivityService: ConnectivityService, + accountManager: UserAccountManager, + powerManagementService: PowerManagementService, + uploads: List + ): Boolean = withContext(Dispatchers.IO) { + var showNotExistMessage = false + var conflictHandlingResult: ConflictHandlingResult? = null + val isOnline = checkConnectivity(connectivityService) + val connectivity = connectivityService.connectivity + val batteryStatus = powerManagementService.battery + + val uploadsToRetry = mutableListOf() + + val currentAccount = accountManager.currentAccount + val context = MainApp.getAppContext() + var ownCloudClient: OwnCloudClient? = null + if (!currentAccount.isAnonymous(context)) { + ownCloudClient = + OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, MainApp.getAppContext()) + } + val uploadActionHandler = UploadListAdapterActionHandler() + + for (upload in uploads) { + if (upload.isLastResultConflictError()) { + ownCloudClient?.let { + conflictHandlingResult = + uploadActionHandler.handleConflict(upload, ownCloudClient, uploadsStorageManager) + } + continue + } + + val uploadResult = checkUploadConditions( + upload, + connectivity, + batteryStatus, + powerManagementService, + isOnline + ) + + if (uploadResult != UploadResult.UPLOADED) { + if (upload.lastResult != uploadResult) { + // Setting Upload status else cancelled uploads will behave wrong, when retrying + // Needs to happen first since lastResult wil be overwritten by setter + upload.uploadStatus = UploadStatus.UPLOAD_FAILED + + upload.lastResult = uploadResult + uploadsStorageManager.updateUpload(upload) + } + if (uploadResult == UploadResult.FILE_NOT_FOUND) { + showNotExistMessage = true + } + continue + } + + // Only uploads that passed checks get marked in progress and are collected for scheduling + upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS + uploadsStorageManager.updateUpload(upload) + uploadsToRetry.add(upload.uploadId) + } + + if (uploadsToRetry.isNotEmpty()) { + backgroundJobManager.startFilesUploadJob( + accountManager.user, + uploadsToRetry.toLongArray(), + false + ) + } + + if (conflictHandlingResult is ConflictHandlingResult.ShowConflictResolveDialog) { + Log_OC.d(TAG, "retry upload skipped, sync conflict: ${conflictHandlingResult.file.remotePath}") + AppWideNotificationManager.showSyncConflictNotification(MainApp.getAppContext()) + } + + return@withContext showNotExistMessage + } + + @JvmOverloads + @Suppress("LongParameterList") + fun uploadNewFiles( + user: User, + localPaths: Array, + remotePaths: Array, + localBehavior: Int, + createRemoteFolder: Boolean, + createdBy: Int, + requiresWifi: Boolean, + requiresCharging: Boolean, + nameCollisionPolicy: NameCollisionPolicy, + showSameFileAlreadyExistsNotification: Boolean = true + ) { + val uploads = localPaths.mapIndexed { index, localPath -> + fun createOCUpload(): OCUpload { + val result = OCUpload(localPath, remotePaths[index], user.accountName).apply { + this.nameCollisionPolicy = nameCollisionPolicy + isUseWifiOnly = requiresWifi + isWhileChargingOnly = requiresCharging + uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS + this.createdBy = createdBy + isCreateRemoteFolder = createRemoteFolder + localAction = localBehavior + } + + val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity()) + result.uploadId = id + return result + } + + val entity = getUploadByPaths( + accountName = user.accountName, + localPath = localPath, + remotePath = remotePaths[index] + ) + if (entity != null) { + val capability = fileStorageManager.getCapability(user) + entity.toOCUpload(capability) ?: createOCUpload() + } else { + createOCUpload() + } + } + backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification) + } + + @Suppress("ReturnCount") + fun getUploadByPaths(accountName: String, localPath: String, remotePath: String): UploadEntity? { + val entity = uploadsStorageManager.uploadDao.getUploadByAccountAndPaths( + accountName, + localPath, + remotePath + )?.let { return it } + + val capability = fileStorageManager.getCapability(accountManager.user) + if (!capability.checkWCFRestrictions()) { + // The filesystem should treat files as case-sensitive. For example, "a.TXT" and "a.txt" + // are allowed to exist in the same directory as two distinct files. + return entity + } + + val dotIndex = remotePath.lastIndexOf('.') + if (dotIndex == -1) return null + + val namePart = remotePath.substring(0, dotIndex + 1) + val extension = remotePath.substring(dotIndex + 1) + + // before uploading file remote path may end with uppercase file extension thus we have to search + // via renamed remote path otherwise it will return null + val alternativeExtension = + if (extension == extension.lowercase()) { + extension.uppercase() + } else { + extension.lowercase() + } + + val alternativeRemotePath = namePart + alternativeExtension + + return uploadsStorageManager.uploadDao.getUploadByAccountAndPaths( + accountName, + localPath, + alternativeRemotePath + ) + } + + fun removeFileUpload(remotePath: String, accountName: String) { + uploadsStorageManager.uploadDao.deleteByRemotePathAndAccountName(remotePath, accountName) + } + + @JvmOverloads + fun updateUploadStatus( + remotePath: String, + accountName: String, + status: UploadStatus, + onCompleted: () -> Unit = {} + ) { + ioScope.launch { + uploadsStorageManager.uploadDao.updateStatus(remotePath, accountName, status.value) + onCompleted() + } + } + + suspend fun updateUploadStatuses(remotePaths: List, accountName: String, status: UploadStatus) = + withContext(Dispatchers.IO) { + uploadsStorageManager.uploadDao.updateStatuses(remotePaths, accountName, status.value) + } + + /** + * Retrieves uploads filtered by their status, optionally for a specific account. + * + * This function queries the uploads database asynchronously to obtain a list of uploads + * that match the specified [status]. If an [accountName] is provided, only uploads + * belonging to that account are retrieved. If [accountName] is `null`, uploads with the + * given [status] from **all user accounts** are returned. + * + * @param accountName The name of the account to filter uploads by. + * If `null`, uploads matching the given [status] from all accounts are returned. + * @param status The [UploadStatus] to filter uploads by (e.g., `UPLOAD_FAILED`). + * @param nameCollisionPolicy The [NameCollisionPolicy] to filter uploads by (e.g., `SKIP`). + */ + suspend fun getUploadsByStatus( + accountName: String?, + status: UploadStatus, + capability: OCCapability, + nameCollisionPolicy: NameCollisionPolicy? = null + ): List { + val dao = uploadsStorageManager.uploadDao + return if (accountName != null) { + dao.getUploadsByAccountNameAndStatus(accountName, status.value, nameCollisionPolicy?.serialize()) + } else { + dao.getUploadsByStatus(status.value, nameCollisionPolicy?.serialize()) + }.mapNotNull { + it.toOCUpload(capability) + } + } + + fun cancelAndRestartUploadJob(user: User, uploadIds: LongArray) { + backgroundJobManager.run { + cancelFilesUploadJob(user) + startFilesUploadJob(user, uploadIds, false) + } + } + + @Suppress("ReturnCount") + fun isUploading(remotePath: String?, accountName: String?): Boolean { + accountName ?: return false + if (!backgroundJobManager.isStartFileUploadJobScheduled(accountName)) { + return false + } + + remotePath ?: return false + val upload = uploadsStorageManager.uploadDao.getByRemotePath(remotePath) + return upload?.status == UploadStatus.UPLOAD_IN_PROGRESS.value || + FileUploadWorker.isUploading(remotePath, accountName) + } + + private fun checkConnectivity(connectivityService: ConnectivityService): Boolean { + // check that connection isn't walled off and that the server is reachable + return connectivityService.getConnectivity().isConnected && !connectivityService.isInternetWalled() + } + + /** + * Dupe of [UploadFileOperation.checkConditions], needed to check if the upload should even be scheduled + * @return [UploadResult.UPLOADED] if the upload should be scheduled, otherwise the reason why it shouldn't + */ + private fun checkUploadConditions( + upload: OCUpload, + connectivity: Connectivity, + battery: BatteryStatus, + powerManagementService: PowerManagementService, + hasGeneralConnection: Boolean + ): UploadResult { + var conditions = UploadResult.UPLOADED + + // check that internet is available + if (!hasGeneralConnection) { + conditions = UploadResult.NETWORK_CONNECTION + } + + // check that local file exists; skip the upload otherwise + if (!File(upload.localPath).exists()) { + conditions = UploadResult.FILE_NOT_FOUND + } + + // check that connectivity conditions are met; delay upload otherwise + if (upload.isUseWifiOnly && (!connectivity.isWifi || connectivity.isMetered)) { + conditions = UploadResult.DELAYED_FOR_WIFI + } + + // check if charging conditions are met; delay upload otherwise + if (upload.isWhileChargingOnly && !battery.isCharging && !battery.isFull) { + conditions = UploadResult.DELAYED_FOR_CHARGING + } + + // check that device is not in power save mode; delay upload otherwise + if (powerManagementService.isPowerSavingEnabled) { + conditions = UploadResult.DELAYED_IN_POWER_SAVE_MODE + } + + return conditions + } + + @Suppress("ReturnCount") + fun isUploadingNow(upload: OCUpload?): Boolean { + val currentUploadFileOperation = FileUploadWorker.getCurrentUpload(upload?.uploadId) + if (currentUploadFileOperation == null || currentUploadFileOperation.user == null) return false + if (upload == null || upload.accountName != currentUploadFileOperation.user.accountName) return false + + return if (currentUploadFileOperation.oldFile != null) { + // For file conflicts check old file remote path + upload.remotePath == currentUploadFileOperation.remotePath || + upload.remotePath == currentUploadFileOperation.oldFile!! + .remotePath + } else { + upload.remotePath == currentUploadFileOperation.remotePath + } + } + + @JvmOverloads + fun uploadUpdatedFile( + user: User, + existingFiles: Array, + behaviour: Int, + nameCollisionPolicy: NameCollisionPolicy, + skipAutoUploadCheck: Boolean = false + ) { + Log_OC.d(this, "upload updated file") + + val uploads = existingFiles.map { file -> + file?.let { + fun createOCUpload(): OCUpload { + val result = OCUpload(file, user).apply { + fileSize = file.fileLength + this.nameCollisionPolicy = nameCollisionPolicy + isCreateRemoteFolder = true + this.localAction = behaviour + isUseWifiOnly = false + isWhileChargingOnly = false + uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS + } + + val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity()) + result.uploadId = id + return result + } + + val entity = + file.storagePath?.let { + getUploadByPaths( + accountName = user.accountName, + localPath = it, + remotePath = file.remotePath + ) + } + if (entity != null) { + val capability = fileStorageManager.getCapability(user) + entity.toOCUpload(capability) ?: createOCUpload() + } else { + createOCUpload() + } + } + } + val uploadIds: LongArray = uploads.filterNotNull().map { it.uploadId }.toLongArray() + backgroundJobManager.startFilesUploadJob( + user, + uploadIds, + true, + skipAutoUploadCheck + ) + } + + /** + * Removes any existing file in the same directory that has the same name as the provided new file. + * + * This function checks the parent directory of the given `newFile` for any file with the same name. + * If such a file is found, it is removed using the `RemoveFileOperation`. + * + * @param duplicatedFile File to be deleted + * @param client Needed for executing RemoveFileOperation + * @param user Needed for creating client + */ + fun removeDuplicatedFile(duplicatedFile: OCFile, client: OwnCloudClient, user: User, onCompleted: () -> Unit) { + ioScope.launch { + val removeFileOperation = RemoveFileOperation( + duplicatedFile, + false, + user, + true, + MainApp.getAppContext(), + fileStorageManager + ) + + val result = removeFileOperation.execute(client) + + if (result.isSuccess) { + Log_OC.d(TAG, "Replaced file successfully removed") + + launch(Dispatchers.Main) { + onCompleted() + } + } + } + } + + fun retryUpload(upload: OCUpload, user: User) { + Log_OC.d(this, "retry upload") + + upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS + uploadsStorageManager.updateUpload(upload) + + backgroundJobManager.startFilesUploadJob(user, longArrayOf(upload.uploadId), false) + } + + fun cancel(accountName: String) { + uploadsStorageManager.removeUploads(accountName) + val uploadIds = uploadsStorageManager.getCurrentUploadIds(accountName) + cancelAndRestartUploadJob(accountManager.getUser(accountName).get(), uploadIds) + } + + fun addUploadTransferProgressListener(listener: OnDatatransferProgressListener, targetKey: String) { + mBoundListeners[targetKey] = listener + } + + fun removeUploadTransferProgressListener(listener: OnDatatransferProgressListener, targetKey: String) { + if (mBoundListeners[targetKey] === listener) { + mBoundListeners.remove(targetKey) + } + } + + @Suppress("MagicNumber", "ReturnCount", "ComplexCondition") + fun isSameFileOnRemote(user: User?, localPath: String?, remotePath: String?, context: Context?): Boolean { + if (user == null || localPath == null || remotePath == null || context == null) { + Log_OC.e(TAG, "cannot compare remote and local file") + return false + } + + val operation = ReadFileRemoteOperation(remotePath) + val result: RemoteOperationResult<*> = operation.execute(user, context) + if (result.isSuccess) { + val remoteFile = result.data[0] as RemoteFile + return remoteFile.isSame(localPath) + } + return false + } + + fun showFileUploadLimitMessage(activity: Activity) { + val message = activity.resources.getQuantityString( + R.plurals.file_upload_limit_message, + MAX_FILE_COUNT, + MAX_FILE_COUNT + ) + DisplayUtils.showSnackMessage(activity, message) + } + + class UploadNotificationActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val accountName = intent.getStringExtra(FileUploadEventBroadcaster.EXTRA_ACCOUNT_NAME) + val remotePath = intent.getStringExtra(FileUploadEventBroadcaster.EXTRA_REMOTE_PATH) + val action = intent.action + + if (FileUploadWorker.ACTION_CANCEL_BROADCAST == action) { + Log_OC.d( + FileUploadWorker.TAG, + "Cancel broadcast received for file " + remotePath + " at " + System.currentTimeMillis() + ) + if (accountName == null || remotePath == null) { + return + } + + FileUploadWorker.cancelUpload(remotePath, accountName, onCompleted = { + instance().updateUploadStatus(remotePath, accountName, UploadStatus.UPLOAD_CANCELLED) + }) + } + } + } + + /** + * When a synced folder is disabled or deleted, its associated OCUpload entries in the uploads + * table must be cleaned up. Without this, stale upload entries outlive the folder config that + * created them, causing FileUploadWorker to keep retrying uploads for a folder that no longer + * exists or is intentionally turned off, and AutoUploadWorker to re-queue already handled files + * on its next scan via FileSystemRepository.getFilePathsWithIds. + */ + suspend fun removeEntityFromUploadEntities(id: Long) { + uploadsStorageManager.fileSystemDao.getBySyncedFolderId(id.toString()) + .filter { it.localPath != null && it.remotePath != null } + .forEach { + Log_OC.d( + TAG, + "deleting upload entity localPath: ${it.localPath}, " + "remotePath: ${it.remotePath}" + ) + uploadsStorageManager.uploadDao.deleteByLocalRemotePath( + localPath = it.localPath!!, + remotePath = it.remotePath!! + ) + } + } + + /** + * Splits a list of files into: + * 1. Files that have an auto-upload folder configured. + * 2. Files that don't. + */ + suspend fun splitFilesByAutoUpload( + files: List, + accountName: String + ): Pair, List> { + val autoUploadFolders = mutableListOf() + val nonAutoUploadFiles = mutableListOf() + + for (file in files) { + val entity = getAutoUploadFolderEntity(file, accountName) + if (entity != null) { + autoUploadFolders.add(entity) + } else { + nonAutoUploadFiles.add(file) + } + } + + return autoUploadFolders to nonAutoUploadFiles + } + + suspend fun getAutoUploadFolderEntity(file: ServerFileInterface, accountName: String): SyncedFolderEntity? { + val dao = uploadsStorageManager.syncedFolderDao + val normalizedRemotePath = file.remotePath.trimEnd() + if (normalizedRemotePath.isEmpty()) return null + return dao.findByRemotePathAndAccount(normalizedRemotePath, accountName) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt new file mode 100644 index 000000000000..2bde79359b68 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -0,0 +1,451 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.BackgroundJobManagerImpl +import com.nextcloud.client.jobs.autoUpload.FileSystemRepository +import com.nextcloud.client.jobs.autoUpload.SyncFolderHelper +import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.ForegroundServiceHelper +import com.nextcloud.utils.extensions.getPercent +import com.nextcloud.utils.extensions.toFile +import com.nextcloud.utils.extensions.updateStatus +import com.owncloud.android.R +import com.owncloud.android.datamodel.ForegroundServiceType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.factory.UploadFileOperationFactory +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import kotlin.random.Random + +@Suppress("LongParameterList", "TooGenericExceptionCaught") +class FileUploadWorker( + val uploadsStorageManager: UploadsStorageManager, + val connectivityService: ConnectivityService, + val powerManagementService: PowerManagementService, + val userAccountManager: UserAccountManager, + val viewThemeUtils: ViewThemeUtils, + val localBroadcastManager: LocalBroadcastManager, + private val backgroundJobManager: BackgroundJobManager, + val preferences: AppPreferences, + val filesystemRepository: FileSystemRepository, + val syncedFolderProvider: SyncedFolderProvider, + val context: Context, + val uploadFileOperationFactory: UploadFileOperationFactory, + params: WorkerParameters +) : CoroutineWorker(context, params), + OnDatatransferProgressListener { + + companion object { + val TAG: String = FileUploadWorker::class.java.simpleName + + const val NOTIFICATION_ERROR_ID: Int = 413 + + const val ACCOUNT = "data_account" + const val UPLOAD_IDS = "uploads_ids" + const val CURRENT_BATCH_INDEX = "batch_index" + const val TOTAL_UPLOAD_SIZE = "total_upload_size" + const val SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION = "show_same_file_already_exists_notification" + const val SKIP_AUTO_UPLOAD_CHECK = "skip_auto_upload_check" + private const val BATCH_SIZE = 100 + + const val EXTRA_ACCOUNT_NAME = "ACCOUNT_NAME" + const val ACTION_CANCEL_BROADCAST = "CANCEL" + const val LOCAL_BEHAVIOUR_COPY = 0 + const val LOCAL_BEHAVIOUR_MOVE = 1 + const val LOCAL_BEHAVIOUR_FORGET = 2 + const val LOCAL_BEHAVIOUR_DELETE = 3 + + private val activeOperations = ConcurrentHashMap() + + @JvmOverloads + fun cancelUpload(remotePath: String?, accountName: String?, onCompleted: () -> Unit = {}) { + val operation = + activeOperations.values.find { it.remotePath == remotePath && it.user.accountName == accountName } + + operation?.let { + Log_OC.d(TAG, "upload operation is cancelled: $remotePath") + operation.cancel(ResultCode.USER_CANCELLED) + activeOperations.remove(operation.ocUploadId) + } + + onCompleted() + } + + suspend fun cancelUploads(remotePaths: List, accountName: String) { + withContext(Dispatchers.IO) { + remotePaths.forEach { + cancelUpload(it, accountName) + } + } + } + + fun getCurrentUpload(id: Long?): UploadFileOperation? = activeOperations[id] + + fun isUploading(remotePath: String?, accountName: String?): Boolean = activeOperations.values.any { + it.remotePath == remotePath && it.user.accountName == accountName + } + + fun getUploadAction(action: String): Int = when (action) { + "LOCAL_BEHAVIOUR_FORGET" -> LOCAL_BEHAVIOUR_FORGET + "LOCAL_BEHAVIOUR_MOVE" -> LOCAL_BEHAVIOUR_MOVE + "LOCAL_BEHAVIOUR_DELETE" -> LOCAL_BEHAVIOUR_DELETE + else -> LOCAL_BEHAVIOUR_FORGET + } + } + + private var lastPercent = 0 + private val notificationId = Random.nextInt() + private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId) + private val intents = FileUploaderIntents(context) + private val fileUploadEventBroadcaster = FileUploadEventBroadcaster(localBroadcastManager) + + override suspend fun doWork(): Result = try { + trySetForeground() + + Log_OC.d(TAG, "FileUploadWorker started") + val workerName = BackgroundJobManagerImpl.formatClassTag(this::class) + backgroundJobManager.logStartOfWorker(workerName) + + val result = uploadFiles() + backgroundJobManager.logEndOfWorker(workerName, result) + notificationManager.dismissNotification() + result + } catch (t: Throwable) { + Log_OC.e(TAG, "exception $t") + activeOperations.values.forEach { it.cancel(null) } + activeOperations.clear() + Result.failure() + } finally { + // Ensure all database operations are complete before signaling completion + uploadsStorageManager.notifyObserversNow() + notificationManager.dismissNotification() + } + + private suspend fun trySetForeground() { + try { + val notificationTitle = notificationManager.currentOperationTitle + ?: context.getString(R.string.foreground_service_upload) + val notification = createNotification(notificationTitle) + updateForegroundInfo(notification) + } catch (e: Exception) { + // Continue without foreground service - uploads will still work + Log_OC.w(TAG, "Could not set foreground service: ${e.message}") + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notificationTitle = notificationManager.currentOperationTitle + ?: context.getString(R.string.foreground_service_upload) + val notification = createNotification(notificationTitle) + + return ForegroundServiceHelper.createWorkerForegroundInfo( + notificationId, + notification, + ForegroundServiceType.DataSync + ) + } + + private suspend fun updateForegroundInfo(notification: Notification) { + val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( + notificationId, + notification, + ForegroundServiceType.DataSync + ) + setForeground(foregroundInfo) + } + + private fun createNotification(title: String): Notification = + NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD) + .setContentTitle(title) + .setSmallIcon(R.drawable.uploads) + .setOngoing(true) + .setSound(null) + .setVibrate(null) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSilent(true) + .build() + + @Suppress("ReturnCount", "LongMethod", "DEPRECATION") + private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) { + val accountName = inputData.getString(ACCOUNT) + if (accountName == null) { + Log_OC.e(TAG, "accountName is null") + return@withContext Result.failure() + } + + val uploadIds = inputData.getLongArray(UPLOAD_IDS) + if (uploadIds == null) { + Log_OC.e(TAG, "uploadIds is null") + return@withContext Result.failure() + } + + val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1) + if (currentBatchIndex == -1) { + Log_OC.e(TAG, "currentBatchIndex is -1, cancelling") + return@withContext Result.failure() + } + + val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1) + if (totalUploadSize == -1) { + Log_OC.e(TAG, "totalUploadSize is -1, cancelling") + return@withContext Result.failure() + } + + // since worker's policy is append or replace and account name comes from there no need check in the loop + val optionalUser = userAccountManager.getUser(accountName) + if (!optionalUser.isPresent) { + Log_OC.e(TAG, "User not found for account: $accountName") + return@withContext Result.failure() + } + + val skipAutoUploadCheck = inputData.getBoolean(SKIP_AUTO_UPLOAD_CHECK, false) + + val user = optionalUser.get() + val previouslyUploadedFileSize = currentBatchIndex * FileUploadHelper.MAX_FILE_COUNT + val uploads = uploadsStorageManager.getUploadsByIds(uploadIds, accountName) + val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) + val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) + val syncFolderHelper = SyncFolderHelper(context) + val syncedFolders = syncedFolderProvider.syncedFolders + + for ((index, upload) in uploads.withIndex()) { + ensureActive() + + if (!skipAutoUploadCheck && isBelongToAnySyncedFolder(upload, syncFolderHelper, syncedFolders)) { + Log_OC.d(TAG, "skipping upload, will be handled by AutoUploadWorker: ${upload.localPath}") + uploadsStorageManager.uploadDao.deleteByRemotePathAndAccountName( + remotePath = upload.remotePath, + accountName = accountName + ) + continue + } + + if (preferences.isGlobalUploadPaused) { + Log_OC.d(TAG, "Upload is paused, skip uploading files!") + notificationManager.notifyPaused( + intents.openUploadListIntent(null) + ) + return@withContext Result.success() + } + + if (canExitEarly()) { + notificationManager.showConnectionErrorNotification() + return@withContext Result.failure() + } + + fileUploadEventBroadcaster.sendUploadEnqueued(context) + val operation = uploadFileOperationFactory.create(upload, this@FileUploadWorker) + activeOperations[upload.uploadId] = operation + + val currentIndex = (index + 1) + val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) + notificationManager.prepareForStart( + operation, + startIntent = intents.openUploadListIntent(operation), + currentUploadIndex = currentUploadIndex, + totalUploadSize = totalUploadSize + ) + + val result = withContext(Dispatchers.IO) { + upload(upload, operation, user, client) + } + activeOperations.remove(upload.uploadId) + + if (result.code == ResultCode.QUOTA_EXCEEDED) { + Log_OC.w(TAG, "Quota exceeded, stopping uploads") + notificationManager.showQuotaExceedNotification(operation) + break + } + + sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) + } + + return@withContext Result.success() + } + + @Suppress("ReturnCount") + suspend fun isBelongToAnySyncedFolder( + upload: OCUpload, + syncFolderHelper: SyncFolderHelper, + syncedFolders: List + ): Boolean { + if (!filesystemRepository.isBelongToAnyAutoFolder(upload.localPath)) return false + + return syncedFolders.any { folder -> + val file = upload.localPath.toFile() ?: return false + val expectedRemotePath = syncFolderHelper.getAutoUploadRemotePath(folder, file) + expectedRemotePath == upload.remotePath + } + } + + private fun sendUploadFinishEvent( + totalUploadSize: Int, + currentUploadIndex: Int, + operation: UploadFileOperation, + result: RemoteOperationResult<*> + ) { + val isLastUpload = currentUploadIndex == totalUploadSize + + val shouldBroadcast = + (currentUploadIndex % BATCH_SIZE == 0 && totalUploadSize > BATCH_SIZE) || + isLastUpload + + if (shouldBroadcast) { + fileUploadEventBroadcaster.sendUploadCompleted( + operation, + result, + context + ) + } + } + + private fun canExitEarly(): Boolean { + val result = !connectivityService.isConnected || + connectivityService.isInternetWalled || + isStopped + + if (result) { + Log_OC.d(TAG, "No internet connection, stopping worker.") + } else { + notificationManager.dismissErrorNotification() + } + + return result + } + + @Suppress("TooGenericExceptionCaught", "DEPRECATION") + private suspend fun upload( + upload: OCUpload, + operation: UploadFileOperation, + user: User, + client: OwnCloudClient + ): RemoteOperationResult = withContext(Dispatchers.IO) { + lateinit var result: RemoteOperationResult + + try { + val storageManager = operation.storageManager + result = operation.execute(client) + val task = ThumbnailsCacheManager.ThumbnailGenerationTask(storageManager, user) + val file = File(operation.originalStoragePath) + val remoteId: String? = operation.file.remoteId + task.execute(ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, remoteId)) + fileUploadEventBroadcaster.sendUploadStarted(operation, context) + } catch (e: Exception) { + Log_OC.e(TAG, "Error uploading", e) + uploadsStorageManager.run { + uploadDao.getUploadById(upload.uploadId, user.accountName)?.let { entity -> + updateStatus( + entity, + UploadsStorageManager.UploadStatus.UPLOAD_FAILED + ) + } + } + result = RemoteOperationResult(e) + } + + if (!isStopped) { + UploadErrorNotificationManager.handleResult( + context, + notificationManager, + operation, + result, + onSameFileConflict = { + withContext(Dispatchers.Main) { + val showSameFileAlreadyExistsNotification = + inputData.getBoolean(SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false) + if (showSameFileAlreadyExistsNotification) { + notificationManager.showSameFileAlreadyExistsNotification(operation.fileName) + } + } + } + ) + } + + return@withContext result + } + + @Suppress("MagicNumber") + private val minProgressUpdateInterval = 750 + private var lastUpdateTime = 0L + + /** + * Receives from [com.owncloud.android.operations.UploadFileOperation.normalUpload] + */ + @Suppress("MagicNumber") + override fun onTransferProgress( + progressRate: Long, + totalTransferredSoFar: Long, + totalToTransfer: Long, + fileAbsoluteName: String + ) { + val percent = getPercent(totalTransferredSoFar, totalToTransfer) + val currentTime = System.currentTimeMillis() + + if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) { + notificationManager.run { + val currentUploadFileOperation = + activeOperations.values.find { it.originalStoragePath == fileAbsoluteName } + + val accountName = currentUploadFileOperation?.user?.accountName + val remotePath = currentUploadFileOperation?.remotePath + + updateUploadProgress(percent, currentUploadFileOperation) + + if (accountName != null && remotePath != null) { + val key: String = FileUploadHelper.buildRemoteName(accountName, remotePath) + val boundListener = FileUploadHelper.mBoundListeners[key] + val filename = currentUploadFileOperation.fileName ?: "" + + boundListener?.onTransferProgress( + progressRate, + totalTransferredSoFar, + totalToTransfer, + filename + ) + } + + dismissOldErrorNotification(currentUploadFileOperation) + } + lastUpdateTime = currentTime + } + + lastPercent = percent + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploaderIntents.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploaderIntents.kt new file mode 100644 index 000000000000..b8341f26cafe --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploaderIntents.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.activity.UploadListActivity + +class FileUploaderIntents(private val context: Context) { + + fun openUploadListIntent(operation: UploadFileOperation?): PendingIntent { + val intent = UploadListActivity.createIntent( + operation?.file, + operation?.user, + Intent.FLAG_ACTIVITY_CLEAR_TOP, + context + ) + + return PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + intent, + PendingIntent.FLAG_IMMUTABLE + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/PostUploadAction.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/PostUploadAction.kt new file mode 100644 index 000000000000..2bbf621d8acf --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/PostUploadAction.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2021 Chris Narkiewicz + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +enum class PostUploadAction(val value: Int) { + NONE(FileUploadWorker.LOCAL_BEHAVIOUR_FORGET), + COPY_TO_APP(FileUploadWorker.LOCAL_BEHAVIOUR_COPY), + MOVE_TO_APP(FileUploadWorker.LOCAL_BEHAVIOUR_MOVE), + DELETE_SOURCE(FileUploadWorker.LOCAL_BEHAVIOUR_DELETE) +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadBroadcastAction.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadBroadcastAction.kt new file mode 100644 index 000000000000..e502484e4144 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadBroadcastAction.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.upload + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.owncloud.android.R +import com.owncloud.android.operations.UploadFileOperation + +sealed class UploadBroadcastAction { + data class PauseAndCancel(val operation: UploadFileOperation) : UploadBroadcastAction() { + + /** + * Updates upload status to CANCELLED + */ + fun pauseAction(context: Context): NotificationCompat.Action = NotificationCompat.Action( + R.drawable.ic_cancel, + context.getString(R.string.pause_upload), + getBroadcast(context, false) + ) + + /** + * Removes the upload completely + */ + fun cancelAction(context: Context): NotificationCompat.Action = NotificationCompat.Action( + R.drawable.ic_delete, + context.getString(R.string.cancel_upload), + getBroadcast(context, true) + ) + + @Suppress("MagicNumber") + private fun getBroadcast(context: Context, remove: Boolean): PendingIntent { + val intent = Intent(context, FileUploadBroadcastReceiver::class.java).apply { + putExtra(FileUploadBroadcastReceiver.UPLOAD_ID, operation.ocUploadId) + putExtra(FileUploadBroadcastReceiver.ACCOUNT_NAME, operation.user.accountName) + putExtra(FileUploadBroadcastReceiver.REMOTE_PATH, operation.remotePath) + putExtra(FileUploadBroadcastReceiver.REMOVE, remove) + action = PauseAndCancel::class.simpleName + + setClass(context, FileUploadBroadcastReceiver::class.java) + setPackage(context.packageName) + } + + val requestCode = if (remove) operation.ocUploadId.toInt() + 1000 else operation.ocUploadId.toInt() + + return PendingIntent.getBroadcast( + context, + requestCode, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt new file mode 100644 index 000000000000..30f0ace023c2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt @@ -0,0 +1,143 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import android.app.PendingIntent +import android.content.Context +import androidx.core.app.NotificationCompat +import com.nextcloud.client.jobs.notification.WorkerNotificationManager +import com.nextcloud.utils.numberFormatter.NumberFormatter +import com.owncloud.android.R +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils + +class UploadNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils, id: Int) : + WorkerNotificationManager( + id, + context, + viewThemeUtils, + tickerId = R.string.foreground_service_upload, + channelId = NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD + ) { + + @Suppress("MagicNumber") + fun prepareForStart( + operation: UploadFileOperation, + startIntent: PendingIntent, + currentUploadIndex: Int, + totalUploadSize: Int + ) { + currentOperationTitle = if (totalUploadSize > 1) { + String.format( + context.getString(R.string.upload_notification_manager_start_text), + currentUploadIndex, + totalUploadSize, + operation.fileName + ) + } else { + operation.fileName + } + + val progressText = NumberFormatter.getPercentageText(0) + + notificationBuilder.run { + setProgress(100, 0, false) + setContentTitle(currentOperationTitle) + setContentText(progressText) + setOngoing(false) + clearActions() + setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.upload_notification_manager_content_intent_description)) + ) + addAction(UploadBroadcastAction.PauseAndCancel(operation).pauseAction(context)) + addAction(UploadBroadcastAction.PauseAndCancel(operation).cancelAction(context)) + setContentIntent(startIntent) + } + + if (!operation.isInstantPicture && !operation.isInstantVideo) { + showNotification() + } + } + + @Suppress("MagicNumber") + fun updateUploadProgress(percent: Int, currentOperation: UploadFileOperation?) { + val progressText = NumberFormatter.getPercentageText(percent) + setProgress(percent, progressText, false) + showNotification() + dismissOldErrorNotification(currentOperation) + } + + fun showSameFileAlreadyExistsNotification(filename: String) { + notificationBuilder.run { + setAutoCancel(true) + clearActions() + setContentText("") + setProgress(0, 0, false) + setContentTitle(context.getString(R.string.file_upload_worker_same_file_already_exists, filename)) + } + + val notificationId = filename.hashCode() + + notificationManager.notify( + notificationId, + notificationBuilder.build() + ) + } + + fun showQuotaExceedNotification(operation: UploadFileOperation) { + val notification = notificationBuilder.run { + setContentTitle(context.getString(R.string.upload_quota_exceeded)) + setContentText("") + clearActions() + setProgress(0, 0, false) + }.build() + + showNotification(operation.file.fileId.toInt(), notification) + } + + fun showConnectionErrorNotification() { + notificationManager.cancel(getId()) + + notificationBuilder.run { + clearActions() + setContentTitle(context.getString(R.string.file_upload_worker_error_notification_title)) + setContentText("") + } + + notificationManager.notify( + FileUploadWorker.NOTIFICATION_ERROR_ID, + notificationBuilder.build() + ) + } + + fun dismissOldErrorNotification(operation: UploadFileOperation?) { + if (operation == null) { + return + } + + dismissNotification(operation.ocUploadId.toInt()) + } + + fun dismissErrorNotification() = notificationManager.cancel(FileUploadWorker.NOTIFICATION_ERROR_ID) + + fun notifyPaused(intent: PendingIntent) { + notificationBuilder.run { + setContentTitle(context.getString(R.string.upload_global_pause_title)) + setTicker(context.getString(R.string.upload_global_pause_title)) + setOngoing(false) + setAutoCancel(false) + setProgress(0, 0, false) + clearActions() + setContentIntent(intent) + } + + showNotification() + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadTask.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadTask.kt new file mode 100644 index 000000000000..efe129f8d765 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadTask.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import android.content.Context +import com.nextcloud.client.account.User +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.network.ConnectivityService +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.operations.UploadFileOperation + +@Suppress("LongParameterList") +class UploadTask( + private val applicationContext: Context, + private val uploadsStorageManager: UploadsStorageManager, + private val connectivityService: ConnectivityService, + private val powerManagementService: PowerManagementService, + private val clientProvider: () -> OwnCloudClient, + private val fileDataStorageManager: FileDataStorageManager +) { + + data class Result(val file: OCFile, val success: Boolean) + + /** + * This class is a helper factory to to keep static dependencies + * injection out of the upload task instance. + */ + @Suppress("LongParameterList") + class Factory( + private val applicationContext: Context, + private val uploadsStorageManager: UploadsStorageManager, + private val connectivityService: ConnectivityService, + private val powerManagementService: PowerManagementService, + private val clientProvider: () -> OwnCloudClient, + private val fileDataStorageManager: FileDataStorageManager + ) { + fun create(): UploadTask = UploadTask( + applicationContext, + uploadsStorageManager, + connectivityService, + powerManagementService, + clientProvider, + fileDataStorageManager + ) + } + + fun upload(user: User, upload: OCUpload): Result { + val file = UploadFileOperation.obtainNewOCFileToUpload( + upload.remotePath, + upload.localPath, + upload.mimeType + ) + val op = UploadFileOperation( + uploadsStorageManager, + connectivityService, + powerManagementService, + user, + file, + upload, + NameCollisionPolicy.ASK_USER, + upload.localAction, + applicationContext, + upload.isUseWifiOnly, + upload.isWhileChargingOnly, + false, + fileDataStorageManager + ) + val client = clientProvider() + uploadsStorageManager.updateDatabaseUploadStart(op) + val result = op.execute(client) + return Result(file, result.isSuccess) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadTrigger.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadTrigger.kt new file mode 100644 index 000000000000..a378f5980f7e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadTrigger.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import com.owncloud.android.operations.UploadFileOperation + +/** + * Upload transfer trigger. + */ +enum class UploadTrigger(val value: Int) { + + /** + * Transfer triggered manually by the user. + */ + USER(UploadFileOperation.CREATED_BY_USER), + + /** + * Transfer triggered automatically by taking a photo. + */ + PHOTO(UploadFileOperation.CREATED_AS_INSTANT_PICTURE), + + /** + * Transfer triggered automatically by making a video. + */ + VIDEO(UploadFileOperation.CREATED_AS_INSTANT_VIDEO); + + companion object { + @JvmStatic + fun fromValue(value: Int) = when (value) { + UploadFileOperation.CREATED_BY_USER -> USER + UploadFileOperation.CREATED_AS_INSTANT_PICTURE -> PHOTO + UploadFileOperation.CREATED_AS_INSTANT_VIDEO -> VIDEO + else -> USER + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt new file mode 100644 index 000000000000..409e66c0c9fd --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -0,0 +1,204 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.utils + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.nextcloud.client.jobs.notification.WorkerNotificationManager +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.UploadBroadcastAction +import com.nextcloud.client.notifications.AppWideNotificationManager +import com.nextcloud.utils.extensions.isConflict +import com.nextcloud.utils.extensions.isFileSpecificError +import com.owncloud.android.R +import com.owncloud.android.authentication.AuthenticatorActivity +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.activity.ConflictsResolveActivity +import com.owncloud.android.utils.ErrorMessageAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object UploadErrorNotificationManager { + private const val TAG = "UploadErrorNotificationManager" + + /** + * Processes the result of an upload operation and manages error notifications. + * * It filters out successful or silent results and handles [ResultCode.SYNC_CONFLICT], [ResultCode.CONFLICT] + * by checking if the remote file is identical. If it's a "real" conflict or error, + * it displays a notification with relevant actions (e.g., Resolve Conflict, Pause, Cancel). + * + * @param onSameFileConflict Triggered only if result code is CONFLICT or SYNC_CONFLICT and files are identical. + */ + @Suppress("ReturnCount") + suspend fun handleResult( + context: Context, + notificationManager: WorkerNotificationManager, + operation: UploadFileOperation, + result: RemoteOperationResult, + onSameFileConflict: suspend () -> Unit = {} + ) { + Log_OC.d(TAG, "handle upload result with result code: " + result.code) + + if (result.isSuccess || operation.isMissingPermissionThrown) { + Log_OC.d(TAG, "operation is successful, cancelled or lack of storage permission, notification skipped") + return + } + + val silentCodes = setOf( + ResultCode.DELAYED_FOR_WIFI, + ResultCode.DELAYED_FOR_CHARGING, + ResultCode.DELAYED_IN_POWER_SAVE_MODE, + ResultCode.LOCAL_FILE_NOT_FOUND, + ResultCode.LOCK_FAILED, + ResultCode.CANCELLED, + ResultCode.USER_CANCELLED + ) + + if (result.code in silentCodes) { + Log_OC.d(TAG, "silent error code, notification skipped") + return + } + + // do not show an error notification when uploading the same file again + if (result.code.isConflict()) { + val isSameFile = withContext(Dispatchers.IO) { + FileUploadHelper.instance().isSameFileOnRemote( + operation.user, + operation.storagePath, + operation.remotePath, + context + ) + } + + if (isSameFile) { + Log_OC.w(TAG, "exact same file already exists on remote, error notification skipped") + + // only show notification for manual uploads + onSameFileConflict() + return + } + } + + // now we can show error notification + val notification = getNotification( + context, + notificationManager.notificationBuilder, + operation, + result + ) + + Log_OC.d(TAG, "🔔" + "notification created") + + withContext(Dispatchers.Main) { + // if error code is file specific show new notification for each file + if (result.code.isFileSpecificError()) { + notificationManager.showNotification(operation.ocUploadId.toInt(), notification) + } else { + notificationManager.showNotification(notification) + } + } + } + + private fun getNotification( + context: Context, + builder: NotificationCompat.Builder, + operation: UploadFileOperation, + result: RemoteOperationResult + ): Notification { + val textId = result.code.toFailedResultTitleId() + val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(result, operation, context.resources) + + return builder.apply { + setTicker(context.getString(textId)) + setContentTitle(context.getString(textId)) + setContentText(errorMessage) + setAutoCancel(false) + setOngoing(false) + setProgress(0, 0, false) + clearActions() + + setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.upload_notification_manager_content_intent_description)) + ) + + // actions for all error types + addAction(UploadBroadcastAction.PauseAndCancel(operation).cancelAction(context)) + + if (result.code.isConflict()) { + addAction( + R.drawable.ic_cloud_upload, + context.getString(R.string.upload_list_resolve_conflict), + conflictResolvePendingIntent(context, operation) + ) + } + + val pendingIntent = if (result.code == ResultCode.UNAUTHORIZED) { + credentialPendingIntent(context, operation) + } else { + AppWideNotificationManager.getUploadListPendingIntent(context) + } + setContentIntent(pendingIntent) + }.build() + } + + private fun ResultCode.toFailedResultTitleId(): Int = when (this) { + ResultCode.UNAUTHORIZED -> R.string.uploader_upload_failed_credentials_error + ResultCode.SYNC_CONFLICT -> R.string.uploader_upload_failed_sync_conflict_error + ResultCode.CONFLICT -> R.string.uploader_upload_failed_sync_conflict_error + else -> R.string.uploader_upload_failed_ticker + } + + @Suppress("DEPRECATION") + private fun credentialPendingIntent(context: Context, operation: UploadFileOperation): PendingIntent { + val intent = Intent(context, AuthenticatorActivity::class.java).apply { + putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, operation.user.toPlatformAccount()) + putExtra(AuthenticatorActivity.EXTRA_ACTION, AuthenticatorActivity.ACTION_UPDATE_EXPIRED_TOKEN) + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or + Intent.FLAG_FROM_BACKGROUND + ) + setClass(context, AuthenticatorActivity::class.java) + setPackage(context.packageName) + } + + return PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + intent, + PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun conflictResolvePendingIntent(context: Context, operation: UploadFileOperation): PendingIntent { + val intent = ConflictsResolveActivity.createIntent( + operation.file, + operation.user, + conflictUploadId = operation.ocUploadId, + Intent.FLAG_ACTIVITY_CLEAR_TOP, + context + ).apply { + setClass(context, ConflictsResolveActivity::class.java) + setPackage(context.packageName) + } + + return PendingIntent.getActivity( + context, + operation.ocUploadId.toInt(), + intent, + PendingIntent.FLAG_IMMUTABLE + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/worker/WorkerFilesPayload.kt b/app/src/main/java/com/nextcloud/client/jobs/worker/WorkerFilesPayload.kt new file mode 100644 index 000000000000..1591f9964c3a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/worker/WorkerFilesPayload.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.worker + +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.FileStorageUtils +import java.io.File + +@Suppress("ReturnCount") +object WorkerFilesPayload { + private const val TAG = "WorkerFilesPayload" + private const val FILE_PREFIX = "worker_files_payload_" + private const val FILE_SUFFIX = ".tmp" + private const val SEPARATOR = "," + + fun write(files: List): String? { + val context = MainApp.getAppContext() ?: return null + if (files.isEmpty()) return null + + val dir = File(FileStorageUtils.getAppTempDirectoryPath(context)).also { + if (!it.exists() && !it.mkdirs()) { + Log_OC.e(TAG, "Failed to create temp directory: ${it.absolutePath}") + return null + } + } + + val file = File(dir, "$FILE_PREFIX${System.currentTimeMillis()}$FILE_SUFFIX") + return runCatching { + file.writeText(files.joinToString(SEPARATOR) { it.fileId.toString() }) + file.absolutePath + }.onFailure { + Log_OC.e(TAG, "Failed to write payload file", it) + }.getOrNull() + } + + fun read(path: String?): List { + if (path.isNullOrBlank()) return listOf() + + val file = File(path) + if (!file.exists()) { + Log_OC.e(TAG, "Payload file not found: $path") + return listOf() + } + + val ids = runCatching { + file.readText() + .split(SEPARATOR) + .mapNotNull { it.toLongOrNull() } + }.onFailure { + Log_OC.e(TAG, "Failed to read payload file", it) + }.getOrNull() ?: return listOf() + + return ids + } + + fun cleanup(path: String?) { + if (path.isNullOrBlank()) return + val deleted = File(path).delete() + if (!deleted) Log_OC.w(TAG, "Failed to delete payload file: $path") + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt b/app/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt new file mode 100644 index 000000000000..dc1409d6dac0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt @@ -0,0 +1,122 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger + +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.nio.charset.Charset + +/** + * Very simple log writer with file rotations. + * + * Files are rotated when writing entry causes log file to exceed it's maximum size. + * Last entry is not truncated and final log file can exceed max file size, but + * no further entries will be written to it. + */ +internal class FileLogHandler(private val logDir: File, private val logFilename: String, private val maxSize: Long) { + + data class RawLogs(val lines: List, val logSize: Long) + + companion object { + const val ROTATED_LOGS_COUNT = 3 + } + + private var writer: FileOutputStream? = null + private var size: Long = 0 + private val rotationList = listOf( + "$logFilename.2", + "$logFilename.1", + "$logFilename.0", + logFilename + ) + + val logFile: File + get() { + return File(logDir, logFilename) + } + + val isOpened: Boolean + get() { + return writer != null + } + + val maxLogFilesCount get() = rotationList.size + + fun open() { + try { + writer = FileOutputStream(logFile, true) + size = logFile.length() + } catch (ex: FileNotFoundException) { + logFile.parentFile.mkdirs() + writer = FileOutputStream(logFile, true) + size = logFile.length() + } + } + + fun write(logEntry: String) { + val rawLogEntry = logEntry.toByteArray(Charset.forName("UTF-8")) + writer?.write(rawLogEntry) + size += rawLogEntry.size + if (size > maxSize) { + rotateLogs() + } + } + + fun close() { + writer?.close() + writer = null + size = 0L + } + + fun deleteAll() { + rotationList + .map { File(logDir, it) } + .forEach { it.delete() } + } + + fun rotateLogs() { + val rotatatingOpenedLog = isOpened + if (rotatatingOpenedLog) { + close() + } + + val existingLogFiles = logDir.listFiles().associate { it.name to it } + existingLogFiles[rotationList.first()]?.delete() + + for (i in 0 until rotationList.size - 1) { + val nextFile = File(logDir, rotationList[i]) + val previousFile = existingLogFiles[rotationList[i + 1]] + previousFile?.renameTo(nextFile) + } + + if (rotatatingOpenedLog) { + open() + } + } + + fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): RawLogs { + if (rotated < 0) { + throw IllegalArgumentException("Negative index") + } + val allLines = mutableListOf() + var size = 0L + for (i in 0..Math.min(rotated, rotationList.size - 1)) { + val file = File(logDir, rotationList[i]) + if (!file.exists()) continue + try { + val lines = file.readLines(Charsets.UTF_8) + allLines.addAll(lines) + size += file.length() + } catch (ex: IOException) { + // ignore failing file + } + } + return RawLogs(lines = allLines, logSize = size) + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/LegacyLoggerAdapter.kt b/app/src/main/java/com/nextcloud/client/logger/LegacyLoggerAdapter.kt new file mode 100644 index 000000000000..0d9f845a43eb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/LegacyLoggerAdapter.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger + +import com.owncloud.android.lib.common.utils.Log_OC + +/** + * This adapter is used by legacy [Log_OC] logger to redirect logs to custom logger implementation. + */ +class LegacyLoggerAdapter(private val logger: Logger) : Log_OC.Adapter { + + override fun i(tag: String, message: String) { + logger.i(tag, message) + } + + override fun d(tag: String, message: String) { + logger.d(tag, message) + } + + override fun d(tag: String, message: String, e: Exception) { + logger.d(tag, message, e) + } + + override fun e(tag: String, message: String) { + logger.e(tag, message) + } + + override fun e(tag: String, message: String, t: Throwable) { + logger.e(tag, message, t) + } + + override fun v(tag: String, message: String) { + logger.v(tag, message) + } + + override fun w(tag: String, message: String) { + logger.w(tag, message) + } + + override fun wtf(tag: String, message: String) { + logger.e(tag, message) + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/Level.kt b/app/src/main/java/com/nextcloud/client/logger/Level.kt new file mode 100644 index 000000000000..cac864e2078d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/Level.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger + +import com.owncloud.android.R + +enum class Level(val tag: String) { + UNKNOWN("U"), + VERBOSE("V"), + DEBUG("D"), + INFO("I"), + WARNING("W"), + ERROR("E"), + ASSERT("A"); + + fun getColor(): Int = when (this) { + UNKNOWN -> R.color.log_level_unknown + VERBOSE -> R.color.log_level_verbose + DEBUG -> R.color.log_level_debug + INFO -> R.color.log_level_info + WARNING -> R.color.log_level_warning + ASSERT -> R.color.log_level_assert + ERROR -> R.color.log_level_error + } + + companion object { + @JvmStatic + fun fromTag(tag: String): Level = when (tag) { + "V" -> VERBOSE + "D" -> DEBUG + "I" -> INFO + "W" -> WARNING + "E" -> ERROR + "A" -> ASSERT + else -> UNKNOWN + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/LogEntry.kt b/app/src/main/java/com/nextcloud/client/logger/LogEntry.kt new file mode 100644 index 000000000000..f105a5550070 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/LogEntry.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger + +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class LogEntry(val timestamp: Date, val level: Level, val tag: String, val message: String) { + + companion object { + private const val UTC_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + private const val TZ_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + private val TIME_ZONE = TimeZone.getTimeZone("UTC") + private val DATE_GROUP_INDEX = 1 + private val LEVEL_GROUP_INDEX = 2 + private val TAG_GROUP_INDEX = 3 + private val MESSAGE_GROUP_INDEX = 4 + + /** + * ;;; + * 1970-01-01T00:00:00.000Z;D;tag;some message + */ + private val ENTRY_PARSE_REGEXP = Regex( + pattern = + """(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z);([ADEIVW]);([^;]+);(.*)""" + ) + + @JvmStatic + fun buildDateFormat(tz: TimeZone? = null): SimpleDateFormat = if (tz == null) { + SimpleDateFormat(UTC_DATE_FORMAT, Locale.US).apply { + timeZone = TIME_ZONE + isLenient = false + } + } else { + SimpleDateFormat(TZ_DATE_FORMAT, Locale.US).apply { + timeZone = tz + isLenient = false + } + } + + @Suppress("ReturnCount") + @JvmStatic + fun parse(s: String): LogEntry? { + val result = ENTRY_PARSE_REGEXP.matchEntire(s) ?: return null + + val date = try { + buildDateFormat().parse(result.groupValues[DATE_GROUP_INDEX]) + } catch (ex: ParseException) { + return null + } + + val level: Level = Level.fromTag(result.groupValues[LEVEL_GROUP_INDEX]) + val tag = result.groupValues[TAG_GROUP_INDEX] + val message = result.groupValues[MESSAGE_GROUP_INDEX].replace("\\n", "\n") + + return LogEntry( + timestamp = date, + level = level, + tag = tag, + message = message + ) + } + } + + override fun toString(): String { + val sb = StringBuilder() + format(sb, buildDateFormat()) + return sb.toString() + } + + fun toString(tz: TimeZone): String { + val sb = StringBuilder() + format(sb, buildDateFormat(tz)) + return sb.toString() + } + + private fun format(sb: StringBuilder, dateFormat: SimpleDateFormat) { + sb.append(dateFormat.format(timestamp)) + sb.append(';') + sb.append(level.tag) + sb.append(';') + sb.append(tag.replace(';', ' ')) + sb.append(';') + sb.append(message.replace("\n", "\\n")) + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/Logger.kt b/app/src/main/java/com/nextcloud/client/logger/Logger.kt new file mode 100644 index 000000000000..9a4610555ba6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/Logger.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger + +interface Logger { + + fun v(tag: String, message: String) + fun d(tag: String, message: String) + fun d(tag: String, message: String, t: Throwable) + fun i(tag: String, message: String) + fun w(tag: String, message: String) + fun e(tag: String, message: String) + fun e(tag: String, message: String, t: Throwable) +} diff --git a/app/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt b/app/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt new file mode 100644 index 000000000000..fad910d4576d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt @@ -0,0 +1,163 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger + +import android.os.Handler +import android.util.Log +import com.nextcloud.client.core.Clock +import java.util.Date +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +@Suppress("TooManyFunctions") +internal class LoggerImpl( + private val clock: Clock, + private val handler: FileLogHandler, + private val mainThreadHandler: Handler, + queueCapacity: Int +) : Logger, + LogsRepository { + + data class Load(val onResult: (List, Long) -> Unit) + class Delete + + private val looper = ThreadLoop() + private val eventQueue: BlockingQueue = LinkedBlockingQueue(queueCapacity) + + private val processedEvents = mutableListOf() + private val otherEvents = mutableListOf() + private val missedLogs = AtomicBoolean() + private val missedLogsCount = AtomicLong() + + override val lostEntries: Boolean + get() { + return missedLogs.get() + } + + fun start() { + looper.start(this::eventLoop) + } + + override fun v(tag: String, message: String) { + Log.v(tag, message) + enqueue(Level.VERBOSE, tag, message) + } + + override fun d(tag: String, message: String) { + Log.d(tag, message) + enqueue(Level.DEBUG, tag, message) + } + + override fun d(tag: String, message: String, t: Throwable) { + Log.d(tag, message, t) + enqueue(Level.DEBUG, tag, message) + } + + override fun i(tag: String, message: String) { + Log.i(tag, message) + enqueue(Level.INFO, tag, message) + } + + override fun w(tag: String, message: String) { + Log.w(tag, message) + enqueue(Level.WARNING, tag, message) + } + + override fun e(tag: String, message: String) { + Log.e(tag, message) + enqueue(Level.ERROR, tag, message) + } + + override fun e(tag: String, message: String, t: Throwable) { + Log.e(tag, message, t) + enqueue(Level.ERROR, tag, message) + } + + override fun load(onLoaded: (entries: List, totalLogSize: Long) -> Unit) { + eventQueue.put(Load(onLoaded)) + } + + override fun deleteAll() { + eventQueue.put(Delete()) + } + + private fun enqueue(level: Level, tag: String, message: String) { + try { + val entry = LogEntry(timestamp = clock.currentDate, level = level, tag = tag, message = message) + val enqueued = eventQueue.offer(entry, 1, TimeUnit.SECONDS) + if (!enqueued) { + missedLogs.set(true) + missedLogsCount.incrementAndGet() + } + } catch (ex: InterruptedException) { + // since interrupted flag is consumed now, we need to re-set the flag so + // the caller can continue handling the thread interruption in it's own way + Thread.currentThread().interrupt() + } + } + + private fun eventLoop() { + try { + processedEvents.clear() + otherEvents.clear() + + processedEvents.add(eventQueue.take()) + eventQueue.drainTo(processedEvents) + + // process all writes in bulk - this is most frequest use case and we can + // assume handler must be opened 99.999% of time; anything that is not a log + // write should be deferred + handler.open() + for (event in processedEvents) { + if (event is LogEntry) { + handler.write(event.toString() + "\n") + } else { + otherEvents.add(event) + } + } + handler.close() + + // Those events are very sporadic and we don't have to be clever here + for (event in otherEvents) { + when (event) { + is Load -> { + val loaded = handler.loadLogFiles() + val entries = loaded.lines.mapNotNull { LogEntry.parse(it) } + mainThreadHandler.post { + event.onResult(entries, loaded.logSize) + } + } + + is Delete -> handler.deleteAll() + } + } + + checkAndLogLostMessages() + } catch (ex: InterruptedException) { + handler.close() + throw ex + } + } + + private fun checkAndLogLostMessages() { + val lastMissedLogsCount = missedLogsCount.getAndSet(0) + if (lastMissedLogsCount > 0) { + handler.open() + val warning = LogEntry( + timestamp = Date(), + level = Level.WARNING, + tag = "Logger", + message = "Logger queue overflow. Approx $lastMissedLogsCount entries lost. You write too much." + ).toString() + handler.write(warning) + handler.close() + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/LogsRepository.kt b/app/src/main/java/com/nextcloud/client/logger/LogsRepository.kt new file mode 100644 index 000000000000..30b44446d0c1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/LogsRepository.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger + +typealias OnLogsLoaded = (entries: List, totalLogSize: Long) -> Unit + +/** + * This interface provides safe, read only access to application + * logs stored on a device. + */ +interface LogsRepository { + + /** + * If true, logger was unable to handle some messages, which means + * it cannot cope with amount of logged data. + * + * This property is thread-safe. + */ + val lostEntries: Boolean + + /** + * Asynchronously load available logs. Load can be scheduled on any thread, + * but the listener will be called on main thread. + * + * @param onLoaded: Callback with loaded logs; called on main thread + */ + fun load(onLoaded: OnLogsLoaded) + + /** + * Asynchronously delete logs. + */ + fun deleteAll() +} diff --git a/app/src/main/java/com/nextcloud/client/logger/ThreadLoop.kt b/app/src/main/java/com/nextcloud/client/logger/ThreadLoop.kt new file mode 100644 index 000000000000..b56fe69fb714 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/ThreadLoop.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger + +/** + * This utility runs provided loop body continuously in a loop on a background thread + * and allows start and stop the loop thread in a safe way. + */ +internal class ThreadLoop { + + private val lock = Object() + private var thread: Thread? = null + private var loopBody: (() -> Unit)? = null + + /** + * Start running [loopBody] in a loop on a background [Thread]. + * If loop is already started, it no-ops. + * + * This method is thread safe. + * + * @throws IllegalStateException if loop is already running + */ + fun start(loopBody: () -> Unit) { + synchronized(lock) { + if (thread == null) { + this.loopBody = loopBody + this.thread = Thread(this::loop) + this.thread?.start() + } + } + } + + /** + * Stops the background [Thread] by interrupting it and waits for [Thread.join]. + * If loop is not started, it no-ops. + * + * This method is thread safe. + * + * @throws IllegalStateException if thread is not running + */ + fun stop() { + synchronized(lock) { + if (thread != null) { + thread?.interrupt() + thread?.join() + } + } + } + + private fun loop() { + try { + while (true) { + loopBody?.invoke() + } + } catch (ex: InterruptedException) { + return + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt b/app/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt new file mode 100644 index 000000000000..d42fc0fd4560 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger.ui + +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.core.Cancellable + +/** + * This utility class allows implementation of as-you-type filtering of large collections. + * + * It asynchronously filters collection in background and provide result via callback on the main thread. + * If new filter request is posted before current filtering task completes, request + * is stored as pending and is handled after currently running task completes. + * + * If a request is already running, another request is already pending and new request is posted + * (ex. if somebody types faster than live search can finish), the pending request is overwritten + * by a new one. + */ +class AsyncFilter(private val asyncRunner: AsyncRunner, private val time: () -> Long = System::currentTimeMillis) { + + private var filterTask: Cancellable? = null + private var pendingRequest: (() -> Unit)? = null + private val isRunning get() = filterTask != null + private var startTime = 0L + + /** + * Schedule filtering request. + * + * @param collection items to appy fitler to; items should not be modified when request is being processed + * @param predicate filter predicate + * @param onResult result callback called on the main thread + */ + fun filter( + collection: Iterable, + predicate: (T) -> Boolean, + onResult: (filtered: List, durationMs: Long) -> Unit + ) { + pendingRequest = { + filterAsync(collection, predicate, onResult) + } + if (!isRunning) { + pendingRequest?.invoke() + } + } + + private fun filterAsync(collection: Iterable, predicate: (T) -> Boolean, onResult: (List, Long) -> Unit) { + startTime = time.invoke() + filterTask = asyncRunner.postQuickTask( + task = { collection.filter { predicate.invoke(it) } }, + onResult = { filtered: List -> + onFilterCompleted(filtered, onResult) + } + ) + pendingRequest = null + } + + private fun onFilterCompleted(filtered: List, callback: (List, Long) -> Unit) { + val dt = time.invoke() - startTime + callback.invoke(filtered, dt) + filterTask = null + startTime = 0L + pendingRequest?.invoke() + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt b/app/src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt new file mode 100644 index 000000000000..cac5c86fca11 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt @@ -0,0 +1,94 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger.ui + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.widget.ProgressBar +import androidx.appcompat.widget.SearchView +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.client.di.ViewModelFactory +import com.owncloud.android.R +import com.owncloud.android.databinding.LogsActivityBinding +import com.owncloud.android.ui.activity.ToolbarActivity +import javax.inject.Inject + +class LogsActivity : ToolbarActivity() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var vm: LogsViewModel + private lateinit var binding: LogsActivityBinding + private lateinit var logsAdapter: LogsAdapter + + private val searchBoxListener = object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean = false + + override fun onQueryTextChange(newText: String): Boolean { + vm.filter(newText) + return false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + vm = ViewModelProvider(this, viewModelFactory).get(LogsViewModel::class.java) + binding = DataBindingUtil.setContentView(this, R.layout.logs_activity).apply { + lifecycleOwner = this@LogsActivity + vm = this@LogsActivity.vm + } + + findViewById(R.id.logs_loading_progress).apply { + viewThemeUtils.platform.themeHorizontalProgressBar(this) + } + + logsAdapter = LogsAdapter(this) + findViewById(R.id.logsList).apply { + layoutManager = LinearLayoutManager(this@LogsActivity) + adapter = logsAdapter + } + + vm.entries.observe(this, Observer { logsAdapter.entries = it }) + vm.load() + + setupToolbar() + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + supportActionBar?.let { + viewThemeUtils.files.themeActionBar(this, it, R.string.logs_title) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_logs, menu) + + (menu.findItem(R.id.action_search).actionView as SearchView).apply { + setOnQueryTextListener(searchBoxListener) + viewThemeUtils.androidx.themeToolbarSearchView(this) + } + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + var retval = true + when (item.itemId) { + android.R.id.home -> finish() + R.id.action_delete_logs -> vm.deleteAll() + R.id.action_send_logs -> vm.send() + R.id.action_export_logs -> vm.export() + R.id.action_refresh_logs -> vm.load() + else -> retval = super.onOptionsItemSelected(item) + } + return retval + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt b/app/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt new file mode 100644 index 000000000000..35def378533b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.client.logger.LogEntry +import com.owncloud.android.R + +class LogsAdapter(private val context: Context) : RecyclerView.Adapter() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val header: TextView? = view.findViewById(R.id.log_entry_list_item_header) + val message: TextView? = view.findViewById(R.id.log_entry_list_item_message) + } + + private val inflater = LayoutInflater.from(context) + + var entries: List = listOf() + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value.sortedBy { it.timestamp } + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(inflater.inflate(R.layout.log_entry_list_item, parent, false)) + + override fun getItemCount() = entries.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val entry = entries[position] + val header = "${entry.timestamp.time} ${entry.level.tag} ${entry.tag}" + val entryColor = ContextCompat.getColor(context, entry.level.getColor()) + + holder.header?.setTextColor(entryColor) + holder.header?.text = header + + holder.message?.setTextColor(entryColor) + holder.message?.text = entry.message + } +} diff --git a/app/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt b/app/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt new file mode 100644 index 000000000000..9055ee527f5e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/ui/LogsEmailSender.kt @@ -0,0 +1,171 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger.ui + +import android.app.DownloadManager +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.core.content.FileProvider +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.core.Cancellable +import com.nextcloud.client.core.Clock +import com.nextcloud.client.logger.LogEntry +import com.owncloud.android.R +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.FileExportUtils +import java.io.File +import java.security.SecureRandom +import java.util.TimeZone + +class LogsEmailSender(private val context: Context, private val clock: Clock, private val runner: AsyncRunner) { + + private companion object { + const val LOGS_MIME_TYPE = "text/plain" + } + + private class Task( + private val context: Context, + private val logs: List, + private val file: File, + private val tz: TimeZone + ) : Function0 { + + override fun invoke(): Uri? { + file.parentFile?.mkdirs() + + file.outputStream().use { outputStream -> + outputStream.writer(Charsets.UTF_8).buffered().use { writer -> + logs.forEach { + writer.write(it.toString(tz)) + writer.newLine() + } + } + } + + return FileProvider.getUriForFile(context, context.getString(R.string.file_provider_authority), file) + } + } + + private var task: Cancellable? = null + + fun send(logs: List) { + if (task == null) { + val outFile = File(context.cacheDir, "attachments/logs.txt") + task = runner.postQuickTask(Task(context, logs, outFile, clock.tz), onResult = { + task = null + send(it) + }) + } + } + + fun export(logs: List) { + if (task == null) { + val outFile = File(context.cacheDir, "attachments/logs.txt") + task = runner.postQuickTask(Task(context, logs, outFile, clock.tz), onResult = { + task = null + export(outFile) + }) + } + } + + fun stop() { + if (task != null) { + task?.cancel() + task = null + } + } + + private fun export(file: File) { + FileExportUtils().exportFile( + "Nextcloud Android Files Logs", + "text/plain", + context.contentResolver, + null, + file + ) + showSuccessNotification(1) + } + + fun showSuccessNotification(successfulExports: Int) { + showNotification( + context.resources.getQuantityString( + R.plurals.export_successful, + successfulExports, + successfulExports + ) + ) + } + + private fun showNotification(message: String) { + val notificationId = SecureRandom().nextInt() + + val notificationBuilder = NotificationCompat.Builder( + context, + NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD + ) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle(message) + .setAutoCancel(true) + + val actionIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS).apply { + flags = FLAG_ACTIVITY_NEW_TASK + } + val actionPendingIntent = PendingIntent.getActivity( + context, + notificationId, + actionIntent, + PendingIntent.FLAG_CANCEL_CURRENT or + PendingIntent.FLAG_IMMUTABLE + ) + notificationBuilder.addAction( + NotificationCompat.Action( + null, + context.getString(R.string.locate_folder), + actionPendingIntent + ) + ) + + val notificationManager = context + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + private fun send(uri: Uri?) { + task = null + val intent = Intent(Intent.ACTION_SEND_MULTIPLE) + intent.putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.mail_logger)) + + val subject = context.getString(R.string.log_send_mail_subject).format(context.getString(R.string.app_name)) + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + + intent.putExtra(Intent.EXTRA_TEXT, getPhoneInfo()) + + intent.flags = FLAG_ACTIVITY_NEW_TASK + intent.type = LOGS_MIME_TYPE + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(uri)) + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, R.string.log_send_no_mail_app, Toast.LENGTH_SHORT).show() + } + } + + private fun getPhoneInfo(): String = "Model: " + Build.MODEL + "\n" + + "Brand: " + Build.BRAND + "\n" + + "Product: " + Build.PRODUCT + "\n" + + "Device: " + Build.DEVICE + "\n" + + "Version-Codename: " + Build.VERSION.CODENAME + "\n" + + "Version-Release: " + Build.VERSION.RELEASE +} diff --git a/app/src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt b/app/src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt new file mode 100644 index 000000000000..9075c8a730d2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt @@ -0,0 +1,126 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.logger.ui + +import android.annotation.SuppressLint +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.core.Clock +import com.nextcloud.client.logger.LogEntry +import com.nextcloud.client.logger.LogsRepository +import com.owncloud.android.R +import javax.inject.Inject + +@SuppressLint("StaticFieldLeak") +class LogsViewModel @Inject constructor( + private val context: Context, + clock: Clock, + asyncRunner: AsyncRunner, + private val logsRepository: LogsRepository +) : ViewModel() { + + private companion object { + const val KILOBYTE = 1024L + } + + private val asyncFilter = AsyncFilter(asyncRunner) + private val sender = LogsEmailSender(context, clock, asyncRunner) + private var allEntries = emptyList() + private var logsSize = -1L + private var filterDurationMs = 0L + private var isFiltered = false + + val isLoading: LiveData = MutableLiveData().apply { value = false } + val size: LiveData = MutableLiveData().apply { value = 0 } + val entries: LiveData> = MutableLiveData>().apply { value = emptyList() } + val status: LiveData = MutableLiveData().apply { value = "" } + + fun send() { + entries.value?.let { + sender.send(it) + } + } + + fun export() { + entries.value?.let { + sender.export(it) + } + } + + fun load() { + if (isLoading.value != true) { + logsRepository.load(this::onLoaded) + (isLoading as MutableLiveData).value = true + } + } + + private fun onLoaded(entries: List, logsSize: Long) { + this.entries as MutableLiveData + this.isLoading as MutableLiveData + this.status as MutableLiveData + + this.entries.value = entries + this.allEntries = entries + this.logsSize = logsSize + isLoading.value = false + this.status.value = formatStatus() + } + + fun deleteAll() { + logsRepository.deleteAll() + (entries as MutableLiveData).value = emptyList() + } + + fun filter(pattern: String) { + if (isLoading.value == false) { + isFiltered = pattern.isNotEmpty() + val predicate = when (isFiltered) { + true -> { it: LogEntry -> it.tag.contains(pattern, true) || it.message.contains(pattern, true) } + false -> { _ -> true } + } + asyncFilter.filter( + collection = allEntries, + predicate = predicate, + onResult = this::onFiltered + ) + } + } + + override fun onCleared() { + super.onCleared() + sender.stop() + } + + private fun onFiltered(filtered: List, filterDurationMs: Long) { + (entries as MutableLiveData).value = filtered + this.filterDurationMs = filterDurationMs + (status as MutableLiveData).value = formatStatus() + } + + private fun formatStatus(): String { + val displayedEntries = entries.value?.size ?: allEntries.size + val sizeKb = logsSize / KILOBYTE + return when { + isLoading.value == true -> context.getString(R.string.logs_status_loading) + + isFiltered -> context.getString( + R.string.logs_status_filtered, + sizeKb, + displayedEntries, + allEntries.size, + filterDurationMs + ) + + !isFiltered -> context.getString(R.string.logs_status_not_filtered, sizeKb) + + else -> "" + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt b/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt new file mode 100644 index 000000000000..c60702bb9b47 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.media.AudioManager + +/** + * Simplified audio focus values, relevant to application's media player experience. + */ +internal enum class AudioFocus { + + LOST, + DUCK, + FOCUS; + + companion object { + fun fromPlatformFocus(audioFocus: Int): AudioFocus? = when (audioFocus) { + AudioManager.AUDIOFOCUS_GAIN -> FOCUS + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> FOCUS + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> FOCUS + AudioManager.AUDIOFOCUS_LOSS -> LOST + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> LOST + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> DUCK + else -> null + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt b/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt new file mode 100644 index 000000000000..3cd1e1da7166 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.media.AudioFocusRequest +import android.media.AudioManager + +/** + * Wrapper around audio manager exposing simplified audio focus API and + * hiding platform API level differences. + * + * @param audioManger Platform audio manager + * @param onFocusChange Called when audio focus changes, including acquired and released focus states + */ +internal class AudioFocusManager( + private val audioManger: AudioManager, + private val onFocusChange: (AudioFocus) -> Unit, + requestBuilder: AudioFocusRequest.Builder = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) +) { + private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + val focus = when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> AudioFocus.FOCUS + + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> AudioFocus.LOST + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> AudioFocus.DUCK + + else -> null + } + focus?.let { onFocusChange(it) } + } + + private val focusRequest = requestBuilder + .setWillPauseWhenDucked(true) + .setOnAudioFocusChangeListener(focusListener) + .build() + + fun requestFocus() { + val requestResult = audioManger.requestAudioFocus(focusRequest) + if (requestResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) + } else { + focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) + } + } + + fun releaseFocus() { + audioManger.abandonAudioFocusRequest(focusRequest) + focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt b/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt new file mode 100644 index 000000000000..2c40303f9bcb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt @@ -0,0 +1,294 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Parneet Singh + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.media + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.media3.common.Player +import androidx.media3.common.Player.COMMAND_PLAY_PAUSE +import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT +import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS +import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.common.NextcloudClient +import com.nextcloud.utils.extensions.registerBroadcastReceiver +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.datamodel.ReceiverFlag +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.notifications.NotificationUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@OptIn(UnstableApi::class) +class BackgroundPlayerService : + MediaSessionService(), + Injectable { + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val seekBackSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_BACK, Bundle.EMPTY) + private val seekForwardSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_FORWARD, Bundle.EMPTY) + + private lateinit var seekForward: CommandButton + private lateinit var seekBackward: CommandButton + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var userAccountManager: UserAccountManager + + private lateinit var exoPlayer: ExoPlayer + private var mediaSession: MediaSession? = null + private var isPlayerReady = false + + private val stopReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + RELEASE_MEDIA_SESSION_BROADCAST_ACTION -> release() + + STOP_MEDIA_SESSION_BROADCAST_ACTION -> { + if (isPlayerReady) { + exoPlayer.stop() + } else { + stopSelf() + } + } + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = NotificationCompat.Builder(this, NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) + .setSmallIcon(R.drawable.logo) + .setContentTitle(getString(R.string.media_player_playing)) + .setSilent(true) + .build() + + ServiceCompat.startForeground( + this, + DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } else { + 0 + } + ) + + return super.onStartCommand(intent, flags, startId) + } + + @Suppress("DEPRECATION") + override fun onCreate() { + super.onCreate() + + MainApp.getAppComponent().inject(this) + + seekForward = CommandButton.Builder() + .setDisplayName(getString(R.string.media_player_seek_forward)) + .setIconResId(R.drawable.ic_skip_next) + .setSessionCommand(seekForwardSessionCommand) + .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 2) }) + .build() + + seekBackward = CommandButton.Builder() + .setDisplayName(getString(R.string.media_player_seek_backward)) + .setIconResId(R.drawable.ic_skip_previous) + .setSessionCommand(seekBackSessionCommand) + .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 0) }) + .build() + + exoPlayer = ExoPlayer.Builder(this).build() + mediaSession = buildMediaSession(exoPlayer) + + setMediaNotificationProvider(buildNotificationProvider()) + + registerBroadcastReceiver( + stopReceiver, + IntentFilter().apply { + addAction(RELEASE_MEDIA_SESSION_BROADCAST_ACTION) + addAction(STOP_MEDIA_SESSION_BROADCAST_ACTION) + }, + ReceiverFlag.NotExported + ) + + initExoPlayer() + } + + @Suppress("TooGenericExceptionCaught") + private fun initExoPlayer() { + serviceScope.launch { + try { + val nextcloudClient: NextcloudClient = withContext(Dispatchers.IO) { + clientFactory.createNextcloudClient(userAccountManager.user) + } + + val realPlayer = createNextcloudExoplayer(this@BackgroundPlayerService, nextcloudClient) + exoPlayer.release() + exoPlayer = realPlayer + isPlayerReady = true + + // Update the session to use the real player + mediaSession?.player = realPlayer + } catch (e: Exception) { + Log_OC.e(TAG, "Failed to initialise Nextcloud ExoPlayer: ${e.message}") + stopSelf() + } + } + } + + private fun buildMediaSession(player: ExoPlayer): MediaSession = MediaSession.Builder(applicationContext, player) + .setId(BACKGROUND_MEDIA_SESSION_ID) + .setCustomLayout(listOf(seekBackward, seekForward)) + .setCallback(object : MediaSession.Callback { + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): ConnectionResult = + AcceptedResultBuilder(mediaSession ?: session) + .setAvailablePlayerCommands( + ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() + .remove(COMMAND_SEEK_TO_NEXT) + .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .remove(COMMAND_SEEK_TO_PREVIOUS) + .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build() + ) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + .addSessionCommands( + listOf(seekBackSessionCommand, seekForwardSessionCommand) + ).build() + ) + .build() + + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { + session.setCustomLayout(listOf(seekBackward, seekForward)) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture = when (customCommand.customAction) { + SESSION_COMMAND_ACTION_SEEK_FORWARD -> { + session.player.seekForward() + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + SESSION_COMMAND_ACTION_SEEK_BACK -> { + session.player.seekBack() + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + else -> super.onCustomCommand(session, controller, customCommand, args) + } + }) + .build() + + private fun buildNotificationProvider() = object : DefaultMediaNotificationProvider(this) { + @Suppress("DEPRECATION") + override fun getMediaButtons( + session: MediaSession, + playerCommands: Player.Commands, + customLayout: ImmutableList, + showPauseButton: Boolean + ): ImmutableList { + val isPlaying = mediaSession?.player?.isPlaying == true + val playPauseButton = CommandButton.Builder() + .setDisplayName( + if (isPlaying) { + getString(R.string.media_player_pause) + } else { + getString(R.string.media_player_play) + } + ) + .setIconResId(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow) + .setPlayerCommand(COMMAND_PLAY_PAUSE) + .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 1) }) + .build() + + return ImmutableList.of(seekBackward, playPauseButton, seekForward) + } + } + + override fun onTaskRemoved(rootIntent: Intent?) { + release() + } + + override fun onDestroy() { + unregisterReceiver(stopReceiver) + serviceScope.cancel() + mediaSession?.run { + player.release() + release() + mediaSession = null + } + super.onDestroy() + } + + private fun release() { + val player = mediaSession?.player + if (player?.playWhenReady == true) { + player.pause() + } + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.cancel(DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + + companion object { + private val TAG = BackgroundPlayerService::class.java.simpleName + + private const val SESSION_COMMAND_ACTION_SEEK_BACK = "SESSION_COMMAND_ACTION_SEEK_BACK" + private const val SESSION_COMMAND_ACTION_SEEK_FORWARD = "SESSION_COMMAND_ACTION_SEEK_FORWARD" + private const val BACKGROUND_MEDIA_SESSION_ID = + "com.nextcloud.client.media.BACKGROUND_MEDIA_SESSION_ID" + + const val RELEASE_MEDIA_SESSION_BROADCAST_ACTION = + "com.nextcloud.client.media.RELEASE_MEDIA_SESSION" + const val STOP_MEDIA_SESSION_BROADCAST_ACTION = + "com.nextcloud.client.media.STOP_MEDIA_SESSION" + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt b/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt new file mode 100644 index 000000000000..8b34266453a7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt @@ -0,0 +1,113 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.content.Context +import android.media.MediaPlayer +import androidx.media3.common.PlaybackException +import com.owncloud.android.R + +/** + * This code has been moved from legacy media player service. + */ +@Deprecated("This legacy helper should be refactored") +@Suppress("ComplexMethod") // it's legacy code +object ErrorFormat { + + /** Error code for specific messages - see regular error codes at [MediaPlayer] */ + const val OC_MEDIA_ERROR = 0 + + @JvmStatic + fun toString(context: Context?, what: Int, extra: Int): String { + val messageId: Int + + if (what == OC_MEDIA_ERROR) { + messageId = extra + } else if (extra == MediaPlayer.MEDIA_ERROR_UNSUPPORTED) { + /* Added in API level 17 + Bitstream is conforming to the related coding standard or file spec, + but the media framework does not support the feature. + Constant Value: -1010 (0xfffffc0e) + */ + messageId = R.string.media_err_unsupported + } else if (extra == MediaPlayer.MEDIA_ERROR_IO) { + /* Added in API level 17 + File or network related operation errors. + Constant Value: -1004 (0xfffffc14) + */ + messageId = R.string.media_err_io + } else if (extra == MediaPlayer.MEDIA_ERROR_MALFORMED) { + /* Added in API level 17 + Bitstream is not conforming to the related coding standard or file spec. + Constant Value: -1007 (0xfffffc11) + */ + messageId = R.string.media_err_malformed + } else if (extra == MediaPlayer.MEDIA_ERROR_TIMED_OUT) { + /* Added in API level 17 + Some operation takes too long to complete, usually more than 3-5 seconds. + Constant Value: -110 (0xffffff92) + */ + messageId = R.string.media_err_timeout + } else if (what == MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK) { + /* Added in API level 3 + The video is streamed and its container is not valid for progressive playback i.e the video's index + (e.g moov atom) is not at the start of the file. + Constant Value: 200 (0x000000c8) + */ + messageId = R.string.media_err_invalid_progressive_playback + } else { + /* MediaPlayer.MEDIA_ERROR_UNKNOWN + Added in API level 1 + Unspecified media player error. + Constant Value: 1 (0x00000001) + */ + /* MediaPlayer.MEDIA_ERROR_SERVER_DIED) + Added in API level 1 + Media server died. In this case, the application must release the MediaPlayer + object and instantiate a new one. + Constant Value: 100 (0x00000064) + */ + messageId = R.string.media_err_unknown + } + return context?.getString(messageId) ?: "Media error" + } + + fun toString(context: Context, exception: PlaybackException): String { + val messageId = when (exception.errorCode) { + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> { + R.string.media_err_unsupported + } + + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE -> { + R.string.media_err_io + } + + PlaybackException.ERROR_CODE_TIMEOUT -> { + R.string.media_err_timeout + } + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> { + R.string.media_err_malformed + } + + else -> { + R.string.media_err_invalid_progressive_playback + } + } + return context.getString(messageId) + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt b/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt new file mode 100644 index 000000000000..3b0dd56ef341 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.content.Context +import android.content.DialogInterface +import android.view.View +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC + +class ExoplayerListener( + private val context: Context, + private val playerView: View, + private val exoPlayer: ExoPlayer, + private val onCompleted: () -> Unit = { } +) : Player.Listener { + + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + if (playbackState == Player.STATE_ENDED) { + onCompletion() + onCompleted() + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + Log_OC.d(TAG, "Exoplayer keep screen on: $isPlaying") + playerView.keepScreenOn = isPlaying + } + + private fun onCompletion() { + exoPlayer.let { + it.seekToDefaultPosition() + it.pause() + } + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + Log_OC.e(TAG, "Exoplayer error", error) + val message = ErrorFormat.toString(context, error) + MaterialAlertDialogBuilder(context) + .setMessage(message) + .setPositiveButton(R.string.common_ok) { _: DialogInterface?, _: Int -> + onCompletion() + } + .setCancelable(false) + .show() + } + + companion object { + private const val TAG = "ExoplayerListener" + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt b/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt new file mode 100644 index 000000000000..ea69556ae48d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.os.AsyncTask +import com.owncloud.android.files.StreamMediaFileOperation +import com.owncloud.android.lib.common.OwnCloudClient + +internal class LoadUrlTask( + private val client: OwnCloudClient, + private val fileId: Long, + private val onResult: (String?) -> Unit +) : AsyncTask() { + + override fun doInBackground(vararg args: Void): String? { + val operation = StreamMediaFileOperation(fileId) + val result = operation.execute(client) + return when (result.isSuccess) { + true -> result.data[0] as String + false -> null + } + } + + override fun onPostExecute(url: String?) { + if (!isCancelled) { + onResult(url) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt b/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt new file mode 100644 index 000000000000..283f4e56ba04 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.MainApp + +object NextcloudExoPlayer { + private const val FIVE_SECONDS_IN_MILLIS = 5000L + + /** + * Creates an [ExoPlayer] that uses [NextcloudClient] for HTTP connections, thus respecting redirections, + * IP versions and certificates. + * + */ + @OptIn(UnstableApi::class) + @JvmStatic + fun createNextcloudExoplayer(context: Context, nextcloudClient: NextcloudClient): ExoPlayer { + val okHttpDataSourceFactory = OkHttpDataSource.Factory(nextcloudClient.client) + okHttpDataSourceFactory.setUserAgent(MainApp.getUserAgent()) + val mediaSourceFactory = DefaultMediaSourceFactory( + DefaultDataSource.Factory( + context, + okHttpDataSourceFactory + ) + ) + return ExoPlayer + .Builder(context) + .setMediaSourceFactory(mediaSourceFactory) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setSeekForwardIncrementMs(FIVE_SECONDS_IN_MILLIS) + .build() + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/Player.kt b/app/src/main/java/com/nextcloud/client/media/Player.kt new file mode 100644 index 000000000000..0074690ea87d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/Player.kt @@ -0,0 +1,272 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.content.Context +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.PowerManager +import android.widget.MediaController +import com.nextcloud.client.account.User +import com.nextcloud.client.media.PlayerStateMachine.Event +import com.nextcloud.client.media.PlayerStateMachine.State +import com.nextcloud.client.network.ClientFactory +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC + +@Suppress("TooManyFunctions") +internal class Player( + private val context: Context, + private val clientFactory: ClientFactory, + private val listener: Listener? = null, + audioManager: AudioManager, + private val mediaPlayerCreator: () -> MediaPlayer = { MediaPlayer() } +) : MediaController.MediaPlayerControl { + + private companion object { + const val DEFAULT_VOLUME = 1.0f + const val DUCK_VOLUME = 0.1f + const val MIN_DURATION_ALLOWING_SEEK = 3000 + } + + interface Listener { + fun onRunning(file: OCFile) + fun onStart() + fun onPause() + fun onStop() + fun onError(error: PlayerError) + } + + private var stateMachine: PlayerStateMachine + private var loadUrlTask: LoadUrlTask? = null + + private var enqueuedFile: PlaylistItem? = null + + private var playedFile: OCFile? = null + private var startPositionMs: Long = 0 + private var autoPlay = true + private var user: User? = null + private var dataSource: String? = null + private var lastError: PlayerError? = null + private var mediaPlayer: MediaPlayer? = null + private val focusManager = AudioFocusManager(audioManager, this::onAudioFocusChange) + + private val delegate = object : PlayerStateMachine.Delegate { + override val isDownloaded: Boolean get() = playedFile?.isDown ?: false + override val isAutoplayEnabled: Boolean get() = autoPlay + override val hasEnqueuedFile: Boolean get() = enqueuedFile != null + + override fun onStartRunning() { + trace("onStartRunning()") + enqueuedFile.let { + if (it != null) { + playedFile = it.file + startPositionMs = it.startPositionMs + autoPlay = it.autoPlay + user = it.user + dataSource = if (it.file.isDown) it.file.storagePath else null + listener?.onRunning(it.file) + } else { + throw IllegalStateException("Player started without enqueued file.") + } + } + } + + override fun onStartDownloading() { + trace("onStartDownloading()") + checkNotNull(playedFile) { "File not set." } + checkNotNull(user) + playedFile?.let { + val client = clientFactory.create(user) + val task = LoadUrlTask(client, it.localId, this@Player::onDownloaded) + task.execute() + loadUrlTask = task + } + } + + override fun onPrepare() { + trace("onPrepare()") + mediaPlayer = mediaPlayerCreator.invoke() + mediaPlayer?.setOnErrorListener(this@Player::onMediaPlayerError) + mediaPlayer?.setOnPreparedListener(this@Player::onMediaPlayerPrepared) + mediaPlayer?.setOnCompletionListener(this@Player::onMediaPlayerCompleted) + mediaPlayer?.setOnBufferingUpdateListener(this@Player::onMediaPlayerBufferingUpdate) + mediaPlayer?.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK) + mediaPlayer?.setDataSource(dataSource) + mediaPlayer?.setAudioStreamType(AudioManager.STREAM_MUSIC) + mediaPlayer?.setVolume(DEFAULT_VOLUME, DEFAULT_VOLUME) + mediaPlayer?.prepareAsync() + } + + override fun onStopped() { + trace("onStoppped()") + mediaPlayer?.stop() + mediaPlayer?.reset() + mediaPlayer?.release() + mediaPlayer = null + + playedFile = null + startPositionMs = 0 + user = null + autoPlay = true + dataSource = null + loadUrlTask?.cancel(true) + loadUrlTask = null + listener?.onStop() + } + + override fun onError() { + trace("onError()") + this.onStopped() + lastError?.let { + this@Player.listener?.onError(it) + } + if (lastError == null) { + this@Player.listener?.onError(PlayerError("Unknown")) + } + } + + override fun onStartPlayback() { + trace("onStartPlayback()") + mediaPlayer?.start() + listener?.onStart() + } + + override fun onPausePlayback() { + trace("onPausePlayback()") + if (mediaPlayer?.isPlaying == true) { + mediaPlayer?.pause() + listener?.onPause() + } + } + + override fun onRequestFocus() { + trace("onRequestFocus()") + focusManager.requestFocus() + } + + override fun onReleaseFocus() { + trace("onReleaseFocus()") + focusManager.releaseFocus() + } + + override fun onAudioDuck(enabled: Boolean) { + trace("onAudioDuck(): $enabled") + if (enabled) { + mediaPlayer?.setVolume(DUCK_VOLUME, DUCK_VOLUME) + } else { + mediaPlayer?.setVolume(DEFAULT_VOLUME, DEFAULT_VOLUME) + } + } + } + + init { + stateMachine = PlayerStateMachine(delegate) + } + + fun play(item: PlaylistItem) { + if (item.file != playedFile) { + stateMachine.post(Event.STOP) + this.enqueuedFile = item + stateMachine.post(Event.PLAY) + } + } + + fun stop() { + stateMachine.post(Event.STOP) + } + + fun stop(file: OCFile) { + if (playedFile == file) { + stateMachine.post(Event.STOP) + } + } + + private fun onMediaPlayerError(mp: MediaPlayer, what: Int, extra: Int): Boolean { + lastError = PlayerError(ErrorFormat.toString(context, what, extra)) + stateMachine.post(Event.ERROR) + return true + } + + private fun onMediaPlayerPrepared(mp: MediaPlayer) { + trace("onMediaPlayerPrepared()") + stateMachine.post(Event.PREPARED) + } + + private fun onMediaPlayerCompleted(mp: MediaPlayer) { + stateMachine.post(Event.STOP) + } + + private fun onMediaPlayerBufferingUpdate(mp: MediaPlayer, percent: Int) { + trace("onMediaPlayerBufferingUpdate(): $percent") + } + + private fun onDownloaded(url: String?) { + if (url != null) { + dataSource = url + stateMachine.post(Event.DOWNLOADED) + } else { + lastError = PlayerError(context.getString(R.string.media_err_io)) + stateMachine.post(Event.ERROR) + } + } + + private fun onAudioFocusChange(focus: AudioFocus) { + when (focus) { + AudioFocus.FOCUS -> stateMachine.post(Event.FOCUS_GAIN) + AudioFocus.DUCK -> stateMachine.post(Event.FOCUS_DUCK) + AudioFocus.LOST -> stateMachine.post(Event.FOCUS_LOST) + } + } + + private fun trace(fmt: String, vararg args: Any?) { + Log_OC.v(javaClass.simpleName, fmt.format(args)) + } + + // region Media player controls + + override fun isPlaying(): Boolean = stateMachine.isInState(State.PLAYING) + + override fun canSeekForward(): Boolean = duration > MIN_DURATION_ALLOWING_SEEK + + override fun canSeekBackward(): Boolean = duration > MIN_DURATION_ALLOWING_SEEK + + override fun getDuration(): Int { + val hasDuration = setOf(State.PLAYING, State.PAUSED) + .find { stateMachine.isInState(it) } != null + return if (hasDuration) { + mediaPlayer?.duration ?: 0 + } else { + 0 + } + } + + override fun pause() { + stateMachine.post(Event.PAUSE) + } + + override fun getBufferPercentage(): Int = 0 + + override fun seekTo(pos: Int) { + if (stateMachine.isInState(State.PLAYING)) { + mediaPlayer?.seekTo(pos) + } + } + + override fun getCurrentPosition(): Int = mediaPlayer?.currentPosition ?: 0 + + override fun start() { + stateMachine.post(Event.PLAY) + } + + override fun getAudioSessionId(): Int = 0 + + override fun canPause(): Boolean = stateMachine.isInState(State.PLAYING) + + // endregion +} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerError.kt b/app/src/main/java/com/nextcloud/client/media/PlayerError.kt new file mode 100644 index 000000000000..85a380856527 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/PlayerError.kt @@ -0,0 +1,9 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +data class PlayerError(val message: String) diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerService.kt b/app/src/main/java/com/nextcloud/client/media/PlayerService.kt new file mode 100644 index 000000000000..15e6398351c0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/PlayerService.kt @@ -0,0 +1,239 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.media.AudioManager +import android.os.Bundle +import android.os.IBinder +import android.widget.MediaController +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.nextcloud.client.account.User +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.utils.ForegroundServiceHelper +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +import com.owncloud.android.datamodel.ForegroundServiceType +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.ui.preview.PreviewMediaActivity +import com.owncloud.android.utils.theme.ViewThemeUtils +import dagger.android.AndroidInjection +import java.util.Locale +import javax.inject.Inject + +class PlayerService : Service() { + + companion object { + private const val TAG = "PlayerService" + + const val EXTRA_USER = "USER" + const val EXTRA_FILE = "FILE" + const val EXTRA_AUTO_PLAY = "EXTRA_AUTO_PLAY" + const val EXTRA_START_POSITION_MS = "START_POSITION_MS" + const val ACTION_PLAY = "PLAY" + const val ACTION_STOP = "STOP" + const val ACTION_TOGGLE = "TOGGLE" + const val ACTION_STOP_FILE = "STOP_FILE" + + const val IS_MEDIA_CONTROL_LAYOUT_READY = "IS_MEDIA_CONTROL_LAYOUT_READY" + } + + class Binder(val service: PlayerService) : android.os.Binder() { + + /** + * This property returns current instance of media player interface. + * It is not cached and it is suitable for polling. + */ + val player: MediaController.MediaPlayerControl get() = service.player + } + + private val playerListener = object : Player.Listener { + override fun onRunning(file: OCFile) { + Log_OC.d(TAG, "PlayerService.onRunning()") + val intent = Intent(PreviewMediaActivity.MEDIA_CONTROL_READY_RECEIVER).apply { + putExtra(IS_MEDIA_CONTROL_LAYOUT_READY, false) + } + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) + } + + override fun onStart() { + Log_OC.d(TAG, "PlayerService.onStart()") + val intent = Intent(PreviewMediaActivity.MEDIA_CONTROL_READY_RECEIVER).apply { + putExtra(IS_MEDIA_CONTROL_LAYOUT_READY, true) + } + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) + } + + override fun onPause() { + Log_OC.d(TAG, "PlayerService.onPause()") + } + + override fun onStop() { + Log_OC.d(TAG, "PlayerService.onStop()") + stopServiceAndRemoveNotification(null) + } + + override fun onError(error: PlayerError) { + Log_OC.d(TAG, "PlayerService.onError()") + Toast.makeText(this@PlayerService, error.message, Toast.LENGTH_SHORT).show() + } + } + + @Inject + lateinit var audioManager: AudioManager + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + private lateinit var player: Player + private lateinit var notificationBuilder: NotificationCompat.Builder + private var isRunning = false + + override fun onCreate() { + super.onCreate() + + AndroidInjection.inject(this) + player = Player(applicationContext, clientFactory, playerListener, audioManager) + notificationBuilder = NotificationCompat.Builder(this) + viewThemeUtils.androidx.themeNotificationCompatBuilder(this, notificationBuilder) + + val stop = Intent(this, PlayerService::class.java).apply { + action = ACTION_STOP + } + + val pendingStop = PendingIntent.getService(this, 0, stop, PendingIntent.FLAG_IMMUTABLE) + notificationBuilder.addAction(0, getString(R.string.player_stop).lowercase(Locale.getDefault()), pendingStop) + + val toggle = Intent(this, PlayerService::class.java).apply { + action = ACTION_TOGGLE + } + + val pendingToggle = PendingIntent.getService(this, 0, toggle, PendingIntent.FLAG_IMMUTABLE) + notificationBuilder.addAction( + 0, + getString(R.string.player_toggle).lowercase(Locale.getDefault()), + pendingToggle + ) + } + + override fun onBind(intent: Intent?): IBinder? = Binder(this) + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + Log_OC.d(TAG, "player service started") + if (!isRunning) { + val file = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + if (file != null) { + startForeground(file) + } else { + startForegroundWithPlaceholder() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + } + + when (intent.action) { + ACTION_PLAY -> onActionPlay(intent) + ACTION_STOP -> onActionStop() + ACTION_STOP_FILE -> onActionStopFile(intent.extras) + ACTION_TOGGLE -> onActionToggle() + } + return START_NOT_STICKY + } + + private fun startForegroundWithPlaceholder() { + val ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name)) + notificationBuilder.run { + setSmallIcon(R.drawable.ic_play_arrow) + setWhen(System.currentTimeMillis()) + setOngoing(false) + setContentTitle(ticker) + setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) + } + ForegroundServiceHelper.startService( + this, + R.string.media_notif_ticker, + notificationBuilder.build(), + ForegroundServiceType.MediaPlayback + ) + } + + private fun onActionToggle() { + player.run { + if (isPlaying) { + pause() + } else { + start() + } + } + } + + private fun onActionPlay(intent: Intent) { + val user: User = intent.getParcelableArgument(EXTRA_USER, User::class.java)!! + val file: OCFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java)!! + val startPos = intent.getLongExtra(EXTRA_START_POSITION_MS, 0) + val autoPlay = intent.getBooleanExtra(EXTRA_AUTO_PLAY, true) + val item = PlaylistItem(file = file, startPositionMs = startPos, autoPlay = autoPlay, user = user) + player.play(item) + } + + private fun onActionStop() { + stopServiceAndRemoveNotification(null) + } + + private fun onActionStopFile(args: Bundle?) { + val file: OCFile = args?.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + ?: throw IllegalArgumentException("Missing file argument") + stopServiceAndRemoveNotification(file) + } + + private fun startForeground(currentFile: OCFile) { + val ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name)) + val content = getString(R.string.media_state_playing, currentFile.getFileName()) + + notificationBuilder.run { + setSmallIcon(R.drawable.ic_play_arrow) + setWhen(System.currentTimeMillis()) + setOngoing(true) + setContentTitle(ticker) + setContentText(content) + setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) + } + + ForegroundServiceHelper.startService( + this, + R.string.media_notif_ticker, + notificationBuilder.build(), + ForegroundServiceType.MediaPlayback + ) + + isRunning = true + } + + private fun stopServiceAndRemoveNotification(file: OCFile?) { + if (file == null) { + player.stop() + } else { + player.stop(file) + } + + if (isRunning) { + stopForeground(true) + stopSelf() + isRunning = false + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt b/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt new file mode 100644 index 000000000000..2c89ca57dc62 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt @@ -0,0 +1,122 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.widget.MediaController +import androidx.core.content.ContextCompat +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.OCFile + +@Suppress("TooManyFunctions") // implementing large interface +class PlayerServiceConnection(private val context: Context) : MediaController.MediaPlayerControl { + + var isConnected: Boolean = false + private set + + private var binder: PlayerService.Binder? = null + + fun bind() { + val intent = Intent(context, PlayerService::class.java) + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + + fun unbind() { + if (isConnected) { + binder = null + isConnected = false + context.unbindService(connection) + } + } + + fun start(user: User, file: OCFile, playImmediately: Boolean, position: Long) { + val i = Intent(context, PlayerService::class.java).apply { + putExtra(PlayerService.EXTRA_USER, user) + putExtra(PlayerService.EXTRA_FILE, file) + putExtra(PlayerService.EXTRA_AUTO_PLAY, playImmediately) + putExtra(PlayerService.EXTRA_START_POSITION_MS, position) + action = PlayerService.ACTION_PLAY + } + + startForegroundService(i) + } + + fun stop(file: OCFile) { + val i = Intent(context, PlayerService::class.java) + i.putExtra(PlayerService.EXTRA_FILE, file) + i.action = PlayerService.ACTION_STOP_FILE + try { + context.startService(i) + } catch (ex: IllegalStateException) { + // https://developer.android.com/about/versions/oreo/android-8.0-changes#back-all + // ignore it - the service is not running and does not need to be stopped + } + } + + fun stop() { + val i = Intent(context, PlayerService::class.java) + i.action = PlayerService.ACTION_STOP + try { + context.startService(i) + } catch (ex: IllegalStateException) { + // https://developer.android.com/about/versions/oreo/android-8.0-changes#back-all + // ignore it - the service is not running and does not need to be stopped + } + } + + private val connection = object : ServiceConnection { + override fun onServiceDisconnected(name: ComponentName?) { + isConnected = false + binder = null + } + + override fun onServiceConnected(name: ComponentName?, localBinder: IBinder?) { + binder = localBinder as PlayerService.Binder + isConnected = true + } + } + + // region Media controller + + override fun isPlaying(): Boolean = binder?.player?.isPlaying ?: false + + override fun canSeekForward(): Boolean = binder?.player?.canSeekForward() ?: false + + override fun getDuration(): Int = binder?.player?.duration ?: 0 + + override fun pause() { + binder?.player?.pause() + } + + override fun getBufferPercentage(): Int = binder?.player?.bufferPercentage ?: 0 + + override fun seekTo(pos: Int) { + binder?.player?.seekTo(pos) + } + + override fun getCurrentPosition(): Int = binder?.player?.currentPosition ?: 0 + + override fun canSeekBackward(): Boolean = binder?.player?.canSeekBackward() ?: false + + override fun start() { + binder?.player?.start() + } + + override fun getAudioSessionId(): Int = 0 + + override fun canPause(): Boolean = binder?.player?.canPause() ?: false + + // endregion + + private fun startForegroundService(i: Intent) { + ContextCompat.startForegroundService(context, i) + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt b/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt new file mode 100644 index 000000000000..3310244ab455 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt @@ -0,0 +1,216 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import com.github.oxo42.stateless4j.StateMachine +import com.github.oxo42.stateless4j.StateMachineConfig +import com.github.oxo42.stateless4j.delegates.Action +import com.github.oxo42.stateless4j.transitions.Transition +import java.util.ArrayDeque + +/** + * To see visual representation of the state machine, install PlanUml plugin. + * http://plantuml.com/ + * + * @startuml + * + * note "> - entry action\n< - exit action\n[exp] - transition guard\nfunction() - transition action" as README + * + * [*] --> STOPPED + * STOPPED --> RUNNING: PLAY\n[hasEnqueuedFile] + * RUNNING --> STOPPED: STOP\nonStop + * RUNNING --> STOPPED: ERROR\nonError + * RUNNING: >onStartRunning + * + * state RUNNING { + * [*] --> DOWNLOADING: [!isDownloaded] + * [*] --> PREPARING: [isDownloaded] + * DOWNLOADING: >onStartDownloading + * DOWNLOADING --> PREPARING: DOWNLOADED + * + * PREPARING: >onPrepare + * PREPARING --> PLAYING: PREPARED\n[autoPlay] + * PREPARING --> PAUSED: PREPARED\n[!autoPlay] + * PLAYING --> PAUSED: PAUSE\nFOCUS_LOST + * + * PAUSED: >onPausePlayback + * PAUSED --> PLAYING: PLAY + * + * PLAYING: >onRequestFocus + * PLAYING: AWAIT_FOCUS + * AWAIT_FOCUS --> FOCUSED: FOCUS_GAIN\nonStartPlayback() + * FOCUSED -l-> DUCKED: FOCUS_DUCK + * DUCKED: >onAudioDuck(true)\n FOCUSED: FOCUS_GAIN + * } + * } + * + * @enduml + */ +internal class PlayerStateMachine(initialState: State, private val delegate: Delegate) { + + constructor(delegate: Delegate) : this(State.STOPPED, delegate) + + interface Delegate { + val isDownloaded: Boolean + val isAutoplayEnabled: Boolean + val hasEnqueuedFile: Boolean + + fun onStartRunning() + fun onStartDownloading() + fun onPrepare() + fun onStopped() + fun onError() + fun onStartPlayback() + fun onPausePlayback() + fun onRequestFocus() + fun onReleaseFocus() + fun onAudioDuck(enabled: Boolean) + } + + enum class State { + STOPPED, + RUNNING, + RUNNING_INITIAL, + DOWNLOADING, + PREPARING, + PAUSED, + PLAYING, + AWAIT_FOCUS, + FOCUSED, + DUCKED + } + + enum class Event { + PLAY, + DOWNLOADED, + PREPARED, + STOP, + PAUSE, + ERROR, + FOCUS_LOST, + FOCUS_GAIN, + FOCUS_DUCK, + IMMEDIATE_TRANSITION + } + + private var pendingEvents = ArrayDeque() + private var isProcessing = false + private val stateMachine: StateMachine + + /** + * Immediate state machine state. This attribute provides innermost active state. + * For checking parent states, use [PlayerStateMachine.isInState]. + */ + val state: State + get() { + return stateMachine.state + } + + init { + val config = StateMachineConfig() + + config.configure(State.STOPPED) + .permitIf(Event.PLAY, State.RUNNING_INITIAL) { delegate.hasEnqueuedFile } + .onEntryFrom(Event.STOP, delegate::onStopped) + .onEntryFrom(Event.ERROR, delegate::onError) + + config.configure(State.RUNNING) + .permit(Event.STOP, State.STOPPED) + .permit(Event.ERROR, State.STOPPED) + .onEntry(delegate::onStartRunning) + + config.configure(State.RUNNING_INITIAL) + .substateOf(State.RUNNING) + .permitIf(Event.IMMEDIATE_TRANSITION, State.DOWNLOADING, { !delegate.isDownloaded }) + .permitIf(Event.IMMEDIATE_TRANSITION, State.PREPARING, { delegate.isDownloaded }) + .onEntry(this::immediateTransition) + + config.configure(State.DOWNLOADING) + .substateOf(State.RUNNING) + .permit(Event.DOWNLOADED, State.PREPARING) + .onEntry(delegate::onStartDownloading) + + config.configure(State.PREPARING) + .substateOf(State.RUNNING) + .permitIf(Event.PREPARED, State.AWAIT_FOCUS) { delegate.isAutoplayEnabled } + .permitIf(Event.PREPARED, State.PAUSED) { !delegate.isAutoplayEnabled } + .onEntry(delegate::onPrepare) + + config.configure(State.PLAYING) + .substateOf(State.RUNNING) + .permit(Event.PAUSE, State.PAUSED) + .permit(Event.FOCUS_LOST, State.PAUSED) + .onEntry(delegate::onRequestFocus) + .onExit(delegate::onReleaseFocus) + + config.configure(State.PAUSED) + .substateOf(State.RUNNING) + .permit(Event.PLAY, State.AWAIT_FOCUS) + .onEntry(delegate::onPausePlayback) + + config.configure(State.AWAIT_FOCUS) + .substateOf(State.PLAYING) + .permit(Event.FOCUS_GAIN, State.FOCUSED) + + config.configure(State.FOCUSED) + .substateOf(State.PLAYING) + .permit(Event.FOCUS_DUCK, State.DUCKED) + .onEntry(this::onAudioFocusGain) + + config.configure(State.DUCKED) + .substateOf(State.PLAYING) + .permit(Event.FOCUS_GAIN, State.FOCUSED) + .onEntry(Action { delegate.onAudioDuck(true) }) + .onExit(Action { delegate.onAudioDuck(false) }) + + stateMachine = StateMachine(initialState, config) + stateMachine.onUnhandledTrigger { _, _ -> + /* ignore unhandled event */ + } + } + + private fun immediateTransition() { + stateMachine.fire(Event.IMMEDIATE_TRANSITION) + } + + private fun onAudioFocusGain(t: Transition) { + if (t.source == State.AWAIT_FOCUS) { + delegate.onStartPlayback() + } + } + + /** + * Check if state machine is in a given state. + * Contrary to [PlayerStateMachine.state] attribute, this method checks for + * parent states. + */ + fun isInState(state: State): Boolean = stateMachine.isInState(state) + + /** + * Post state machine event to internal queue. + * + * This design ensures that we're not triggering multiple events + * from state machines callbacks before the transition is fully + * completed. + * + * Method is re-entrant. + */ + fun post(event: Event) { + pendingEvents.addLast(event) + if (!isProcessing) { + isProcessing = true + while (pendingEvents.isNotEmpty()) { + val processedEvent = pendingEvents.removeFirst() + stateMachine.fire(processedEvent) + } + isProcessing = false + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt b/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt new file mode 100644 index 000000000000..2415145733ad --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.media + +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.OCFile + +data class PlaylistItem(val file: OCFile, val startPositionMs: Long, val autoPlay: Boolean, val user: User) diff --git a/app/src/main/java/com/nextcloud/client/migrations/MigrationError.kt b/app/src/main/java/com/nextcloud/client/migrations/MigrationError.kt new file mode 100644 index 000000000000..b0c15c9405ca --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/migrations/MigrationError.kt @@ -0,0 +1,11 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +class MigrationError(val id: Int, message: String, cause: Throwable?) : RuntimeException(message, cause) { + constructor(id: Int, message: String) : this(id, message, null) +} diff --git a/app/src/main/java/com/nextcloud/client/migrations/MigrationInfo.kt b/app/src/main/java/com/nextcloud/client/migrations/MigrationInfo.kt new file mode 100644 index 000000000000..135c7b9d59b6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/migrations/MigrationInfo.kt @@ -0,0 +1,9 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +data class MigrationInfo(val id: Int, val description: String, val applied: Boolean) diff --git a/app/src/main/java/com/nextcloud/client/migrations/Migrations.kt b/app/src/main/java/com/nextcloud/client/migrations/Migrations.kt new file mode 100644 index 000000000000..2c3724cc7732 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/migrations/Migrations.kt @@ -0,0 +1,120 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +import androidx.work.WorkManager +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.logger.Logger +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.ui.activity.ContactsPreferenceActivity +import javax.inject.Inject + +/** + * This class collects all migration steps and provides API to supply those + * steps to [MigrationsManager] for execution. + */ +class Migrations @Inject constructor( + private val logger: Logger, + private val userAccountManager: UserAccountManager, + private val workManager: WorkManager, + private val arbitraryDataProvider: ArbitraryDataProvider, + private val jobManager: BackgroundJobManager +) { + + companion object { + val TAG = Migrations::class.java.simpleName + } + + /** + * This class wraps migration logic with some metadata with some + * metadata required to register and log overall migration progress. + * + * @param id Step id; id must be unique; this is verified upon registration + * @param description Human readable migration step descriptions + * @param mandatory If true, failing migration will cause an exception; if false, it will be skipped and repeated + * again on next startup + * @throws Exception migration logic is permitted to throw any kind of exceptions; all exceptions will be wrapped + * into [MigrationException] + */ + class Step(val id: Int, val description: String, val mandatory: Boolean = true, val run: (s: Step) -> Unit) { + override fun toString(): String = "Migration $id: $description" + } + + /** + * NOP migration used to replace applied migrations that should be applied again. + */ + private fun nop(s: Step) { + logger.i(TAG, "$s: skipped deprecated migration") + } + + /** + * Migrate legacy accounts by adding user IDs. This migration can be re-tried until all accounts are + * successfully migrated. + */ + private fun migrateUserId(s: Step) { + val allAccountsHaveUserId = userAccountManager.migrateUserId() + logger.i(TAG, "${s.description}: success = $allAccountsHaveUserId") + if (!allAccountsHaveUserId) { + throw IllegalStateException("Failed to set user id for all accounts") + } + } + + /** + * Content observer job must be restarted to use new scheduler abstraction. + */ + private fun migrateContentObserverJob(s: Step) { + val legacyWork = workManager.getWorkInfosByTag("content_sync").get() + legacyWork.forEach { + logger.i(TAG, "${s.description}: cancelling legacy work ${it.id}") + workManager.cancelWorkById(it.id) + } + jobManager.scheduleContentObserverJob() + logger.i(TAG, "$s: enabled") + } + + /** + * Periodic contacts backup job has been changed and should be restarted. + */ + private fun restartContactsBackupJobs(s: Step) { + val users = userAccountManager.allUsers + if (users.isEmpty()) { + logger.i(TAG, "$s: no users to migrate") + } else { + users.forEach { + val backupEnabled = arbitraryDataProvider.getBooleanValue( + it.accountName, + ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP + ) + if (backupEnabled) { + jobManager.schedulePeriodicContactsBackup(it) + } + logger.i(TAG, "$s: user = ${it.accountName}, backup enabled = $backupEnabled") + } + } + } + + /** + * List of migration steps. Those steps will be loaded and run by [MigrationsManager]. + * + * If a migration should be run again (applicable to periodic job restarts), insert + * the migration with new ID. To prevent accidental re-use of older IDs, replace old + * migration with this::nop. + */ + @Suppress("MagicNumber") + val steps: List = listOf( + Step(0, "Migrate user id", false, this::migrateUserId), + Step(1, "Migrate content observer job", false, this::migrateContentObserverJob), + Step(2, "Restart contacts backup job", true, this::nop), + Step(3, "Restart contacts backup job", true, this::restartContactsBackupJobs) + ).sortedBy { it.id }.apply { + val uniqueIds = associateBy { it.id }.size + if (uniqueIds != size) { + throw IllegalStateException("All migrations must have unique id") + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/migrations/MigrationsDb.kt b/app/src/main/java/com/nextcloud/client/migrations/MigrationsDb.kt new file mode 100644 index 000000000000..235f3cf4e527 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/migrations/MigrationsDb.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +import android.content.SharedPreferences +import androidx.core.content.edit +import java.util.TreeSet + +class MigrationsDb(private val migrationsDb: SharedPreferences) { + + companion object { + const val DB_KEY_LAST_MIGRATED_VERSION = "last_migrated_version" + const val DB_KEY_APPLIED_MIGRATIONS = "applied_migrations" + const val DB_KEY_FAILED = "failed" + const val DB_KEY_FAILED_MIGRATION_ID = "failed_migration_id" + const val DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE = "failed_migration_error" + + const val NO_LAST_MIGRATED_VERSION = -1 + const val NO_FAILED_MIGRATION_ID = -1 + } + + fun getAppliedMigrations(): List { + val appliedIdsStr: Set = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet() + return appliedIdsStr.mapNotNull { + try { + it.toInt() + } catch (_: NumberFormatException) { + null + } + }.sorted() + } + + fun addAppliedMigration(vararg migrations: Int) { + val oldApplied = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet() + val newApplied = TreeSet().apply { + addAll(oldApplied) + addAll(migrations.map { it.toString() }) + } + migrationsDb.edit { putStringSet(DB_KEY_APPLIED_MIGRATIONS, newApplied) } + } + + var lastMigratedVersion: Int + set(value) { + migrationsDb.edit { putInt(DB_KEY_LAST_MIGRATED_VERSION, value) } + } + get() { + return migrationsDb.getInt(DB_KEY_LAST_MIGRATED_VERSION, NO_LAST_MIGRATED_VERSION) + } + + val isFailed: Boolean get() = migrationsDb.getBoolean(DB_KEY_FAILED, false) + val failureReason: String get() = migrationsDb.getString(DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, "") ?: "" + val failedMigrationId: Int get() = migrationsDb.getInt(DB_KEY_FAILED_MIGRATION_ID, NO_FAILED_MIGRATION_ID) + + fun setFailed(id: Int, error: String) { + migrationsDb + .edit { + putBoolean(DB_KEY_FAILED, true) + .putString(DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, error) + .putInt(DB_KEY_FAILED_MIGRATION_ID, id) + } + } + + fun clearMigrations() { + migrationsDb.edit { + putStringSet(DB_KEY_APPLIED_MIGRATIONS, emptySet()) + .putInt(DB_KEY_LAST_MIGRATED_VERSION, 0) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt b/app/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt new file mode 100644 index 000000000000..730762baf147 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData + +/** + * This component allows starting and monitoring of application state migrations. + * Migrations are intended to upgrade any existing, persisted application state + * after upgrade to new version, similarly to database migrations. + */ +interface MigrationsManager { + + enum class Status { + /** + * Application migration was not evaluated yet. This is the default + * state just after [android.app.Application] start + */ + UNKNOWN, + + /** + * All migrations applied successfully. + */ + APPLIED, + + /** + * Migration in progress. + */ + RUNNING, + + /** + * Migration failed. Application is in undefined state. + */ + FAILED + } + + /** + * Listenable migration progress. + */ + val status: LiveData + + /** + * Information about all pending and applied migrations + */ + val info: List + + /** + * Starts application state migration. Migrations will be run in background thread. + * Callers can use [status] to monitor migration progress. + * + * Although the migration process is run in background, status is updated + * immediately and can be accessed immediately after start. + * + * @return Number of migration steps enqueued; 0 if no migrations were started. + */ + @Throws(MigrationError::class) + @MainThread + fun startMigration(): Int +} diff --git a/app/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt b/app/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt new file mode 100644 index 000000000000..eb7a7cc2b16c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.migrations + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.nextcloud.client.appinfo.AppInfo +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.migrations.MigrationsManager.Status + +internal class MigrationsManagerImpl( + private val appInfo: AppInfo, + private val migrationsDb: MigrationsDb, + private val asyncRunner: AsyncRunner, + private val migrations: Collection +) : MigrationsManager { + + override val status: LiveData = MutableLiveData(Status.UNKNOWN) + + override val info: List get() { + val applied = migrationsDb.getAppliedMigrations() + return migrations.map { + MigrationInfo(id = it.id, description = it.description, applied = applied.contains(it.id)) + } + } + + @Throws(MigrationError::class) + @Suppress("ReturnCount") + override fun startMigration(): Int { + if (migrationsDb.isFailed) { + (status as MutableLiveData).value = Status.FAILED + return 0 + } + if (migrationsDb.lastMigratedVersion >= appInfo.versionCode) { + (status as MutableLiveData).value = Status.APPLIED + return 0 + } + val applied = migrationsDb.getAppliedMigrations() + val toApply = migrations.filter { !applied.contains(it.id) } + if (toApply.isEmpty()) { + onMigrationSuccess() + return 0 + } + (status as MutableLiveData).value = Status.RUNNING + asyncRunner.postQuickTask( + task = { asyncApplyMigrations(toApply) }, + onResult = { onMigrationSuccess() }, + onError = { onMigrationFailed(it) } + ) + return toApply.size + } + + /** + * This method calls all pending migrations which can execute long-blocking code. + * It should be run in a background thread. + */ + private fun asyncApplyMigrations(migrations: Collection) { + migrations.forEach { + @Suppress("TooGenericExceptionCaught") // migration code is free to throw anything + try { + it.run.invoke(it) + migrationsDb.addAppliedMigration(it.id) + } catch (t: Throwable) { + if (it.mandatory) { + throw MigrationError(id = it.id, message = t.message ?: t.javaClass.simpleName) + } + } + } + } + + @MainThread + private fun onMigrationFailed(error: Throwable) { + val id = when (error) { + is MigrationError -> error.id + else -> -1 + } + migrationsDb.setFailed(id, error.message ?: error.javaClass.simpleName) + (status as MutableLiveData).value = Status.FAILED + } + + @MainThread + private fun onMigrationSuccess() { + migrationsDb.lastMigratedVersion = appInfo.versionCode + (status as MutableLiveData).value = Status.APPLIED + } +} diff --git a/app/src/main/java/com/nextcloud/client/mixins/ActivityMixin.kt b/app/src/main/java/com/nextcloud/client/mixins/ActivityMixin.kt new file mode 100644 index 000000000000..61761469381e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/mixins/ActivityMixin.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.mixins + +import android.content.Intent +import android.os.Bundle + +/** + * Interface allowing to implement part of [android.app.Activity] logic as + * a mix-in. + */ +interface ActivityMixin { + fun onNewIntent(intent: Intent) { + /* no-op */ + } + + fun onSaveInstanceState(outState: Bundle) { + /* no-op */ + } + + fun onCreate(savedInstanceState: Bundle?) { + /* no-op */ + } + + fun onRestart() { + /* no-op */ + } + + fun onStart() { + /* no-op */ + } + + fun onResume() { + /* no-op */ + } + + fun onPause() { + /* no-op */ + } + + fun onStop() { + /* no-op */ + } + + fun onDestroy() { + /* no-op */ + } +} diff --git a/app/src/main/java/com/nextcloud/client/mixins/MixinRegistry.kt b/app/src/main/java/com/nextcloud/client/mixins/MixinRegistry.kt new file mode 100644 index 000000000000..66a5446b7472 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/mixins/MixinRegistry.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.mixins + +import android.content.Intent +import android.os.Bundle + +/** + * Mix-in registry allows forwards lifecycle calls to all + * registered mix-ins. + * + * Once instantiated, all [android.app.Activity] lifecycle methods + * must call relevant registry companion methods. + * + * Calling the registry from [android.app.Application.ActivityLifecycleCallbacks] is + * not possible as not all callbacks are supported by this interface. + */ +class MixinRegistry : ActivityMixin { + + private val mixins = mutableListOf() + + fun add(vararg mixins: ActivityMixin) { + mixins.forEach { this.mixins.add(it) } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + mixins.forEach { it.onNewIntent(intent) } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mixins.forEach { it.onSaveInstanceState(outState) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mixins.forEach { it.onCreate(savedInstanceState) } + } + + override fun onRestart() { + super.onRestart() + mixins.forEach { it.onRestart() } + } + + override fun onStart() { + super.onStart() + mixins.forEach { it.onStart() } + } + + override fun onResume() { + super.onResume() + mixins.forEach { it.onResume() } + } + + override fun onPause() { + super.onPause() + mixins.forEach { it.onPause() } + } + + override fun onStop() { + super.onStop() + mixins.forEach { it.onStop() } + } + + override fun onDestroy() { + super.onDestroy() + mixins.forEach { it.onDestroy() } + } +} diff --git a/app/src/main/java/com/nextcloud/client/mixins/SessionMixin.kt b/app/src/main/java/com/nextcloud/client/mixins/SessionMixin.kt new file mode 100644 index 000000000000..8acc663a58b1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/mixins/SessionMixin.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.mixins + +import android.accounts.Account +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.utils.extensions.isAnonymous +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.utils.theme.CapabilityUtils +import java.util.Optional + +/** + * Session mixin collects all account / user handling logic currently + * spread over various activities. + * + * It is an intermediary step facilitating comprehensive rework of + * account handling logic. + */ +class SessionMixin(private val activity: Activity, private val accountManager: UserAccountManager) : ActivityMixin { + var currentAccount: Account = getDefaultAccount() + private set + + companion object { + private const val TAG = "SessionMixin" + } + + fun getCapabilities(): Optional { + val optionalUser = getUser() + if (optionalUser.isEmpty) { + Log_OC.e(TAG, "user is empty, returning empty capabilities") + return Optional.empty() + } + + val user = optionalUser.get() + val capability = CapabilityUtils.getCapability(user, activity) + return Optional.of(capability) + } + + fun setAccount(account: Account) { + val validAccount = (accountManager.setCurrentOwnCloudAccount(account.name)) + + currentAccount = if (validAccount) { + account + } else { + getDefaultAccount() + } + } + + fun setUser(user: User) { + setAccount(user.toPlatformAccount()) + } + + fun getUser(): Optional = if (currentAccount.isAnonymous(activity)) { + Optional.empty() + } else { + accountManager.getUser(currentAccount.name) + } + + /** + * Tries to swap the current ownCloud [Account] for other valid and existing. + * + * If no valid ownCloud [Account] exists, then the user is requested + * to create a new ownCloud [Account]. + */ + private fun getDefaultAccount(): Account { + val defaultAccount = accountManager.currentAccount + + if (defaultAccount.isAnonymous(activity)) { + startAccountCreation() + } + + return defaultAccount + } + + /** + * Launches the account creation activity. + */ + fun startAccountCreation() { + accountManager.startAccountCreation(activity) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val current = accountManager.currentAccount + val currentAccount = this.currentAccount + if (!currentAccount.name.equals(current.name)) { + this.currentAccount = current + } + } + + /** + * Since ownCloud {@link Account} can be managed from the system setting menu, the existence of the {@link + * Account} associated to the instance must be checked every time it is restarted. + */ + override fun onRestart() { + super.onRestart() + val validAccount = accountManager.exists(currentAccount) + if (!validAccount) { + getDefaultAccount() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val account = accountManager.currentAccount + setAccount(account) + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/ClientFactory.java b/app/src/main/java/com/nextcloud/client/network/ClientFactory.java new file mode 100644 index 000000000000..a0780bdbeb08 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/ClientFactory.java @@ -0,0 +1,63 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.network; + +import android.accounts.Account; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.app.Activity; +import android.net.Uri; + +import com.nextcloud.client.account.User; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.common.PlainClient; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.accounts.AccountUtils; + +import java.io.IOException; + +public interface ClientFactory { + + /** + * This exception wraps all possible errors thrown by trigger-happy OwnCloudClient constructor, making try-catch + * blocks manageable. + *

+ * This is a temporary refactoring measure, until a better error handling method can be procured. + */ + @Deprecated + class CreationException extends Exception { + + private static final long serialVersionUID = 0L; + + CreationException(Throwable t) { + super(t); + } + } + + OwnCloudClient create(User user) throws CreationException; + + NextcloudClient createNextcloudClient(User user) throws CreationException; + + @Deprecated + OwnCloudClient create(Account account) + throws OperationCanceledException, AuthenticatorException, IOException, + AccountUtils.AccountNotFoundException; + + @Deprecated + OwnCloudClient create(Account account, Activity currentActivity) + throws OperationCanceledException, AuthenticatorException, IOException, + AccountUtils.AccountNotFoundException; + + OwnCloudClient create(Uri uri, + boolean followRedirects, + boolean useNextcloudUserAgent); + + OwnCloudClient create(Uri uri, boolean followRedirects); + + PlainClient createPlainClient(); +} diff --git a/app/src/main/java/com/nextcloud/client/network/ClientFactoryImpl.java b/app/src/main/java/com/nextcloud/client/network/ClientFactoryImpl.java new file mode 100644 index 000000000000..22fe0c85fbf6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/ClientFactoryImpl.java @@ -0,0 +1,83 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.network; + +import android.accounts.Account; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.app.Activity; +import android.content.Context; +import android.net.Uri; + +import com.nextcloud.client.account.User; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.common.PlainClient; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientFactory; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.accounts.AccountUtils; + +import java.io.IOException; + +public class ClientFactoryImpl implements ClientFactory { + + private Context context; + + public ClientFactoryImpl(Context context) { + this.context = context; + } + + @Override + public OwnCloudClient create(User user) throws CreationException { + try { + return OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(user.toOwnCloudAccount(), context); + } catch (OperationCanceledException | + AuthenticatorException | + IOException e) { + throw new CreationException(e); + } + } + + @Override + public NextcloudClient createNextcloudClient(User user) throws CreationException { + try { + return OwnCloudClientFactory.createNextcloudClient(user, context); + } catch (AccountUtils.AccountNotFoundException e) { + throw new CreationException(e); + } + } + + @Override + public OwnCloudClient create(Account account) + throws OperationCanceledException, AuthenticatorException, IOException, + AccountUtils.AccountNotFoundException { + return OwnCloudClientFactory.createOwnCloudClient(account, context); + } + + @Override + public OwnCloudClient create(Account account, Activity currentActivity) + throws OperationCanceledException, AuthenticatorException, IOException, + AccountUtils.AccountNotFoundException { + return OwnCloudClientFactory.createOwnCloudClient(account, context, currentActivity); + } + + @Override + public OwnCloudClient create(Uri uri, boolean followRedirects, boolean useNextcloudUserAgent) { + return OwnCloudClientFactory.createOwnCloudClient(uri, context, followRedirects); + } + + @Override + public OwnCloudClient create(Uri uri, boolean followRedirects) { + return OwnCloudClientFactory.createOwnCloudClient(uri, context, followRedirects); + } + + @Override + public PlainClient createPlainClient() { + return new PlainClient(context); + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/Connectivity.kt b/app/src/main/java/com/nextcloud/client/network/Connectivity.kt new file mode 100644 index 000000000000..e3b4b197c3d3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/Connectivity.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.network + +data class Connectivity( + val isConnected: Boolean = false, + val isMetered: Boolean = false, + val isWifi: Boolean = false, + val isServerAvailable: Boolean? = null +) { + companion object { + @JvmField + val DISCONNECTED = Connectivity() + + @JvmField + val CONNECTED_WIFI = Connectivity( + isConnected = true, + isMetered = false, + isWifi = true, + isServerAvailable = true + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/ConnectivityService.java b/app/src/main/java/com/nextcloud/client/network/ConnectivityService.java new file mode 100644 index 000000000000..7da4afe6ea85 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/ConnectivityService.java @@ -0,0 +1,54 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.network; + + +import androidx.annotation.NonNull; + +/** + * This service provides information about current network connectivity + * and server reachability. + */ +public interface ConnectivityService { + /** + * Checks the availability of the server and the device's internet connection. + *

+ * This method performs a network request to verify if the server is accessible and + * checks if the device has an active internet connection. + *

+ * + * @param callback A callback to handle the result of the network and server availability check. + */ + void isNetworkAndServerAvailable(@NonNull GenericCallback callback); + + boolean isConnected(); + + /** + * Check if server is accessible by issuing HTTP status check request. + * Since this call involves network traffic, it should not be called + * on a main thread. + * + * @return True if server is unreachable, false otherwise + */ + boolean isInternetWalled(); + + /** + * Get current network connectivity status. + * + * @return Network connectivity status in platform-agnostic format + */ + Connectivity getConnectivity(); + + /** + * Callback interface for asynchronous results. + * + * @param The type of result returned by the callback. + */ + interface GenericCallback { + void onComplete(T result); + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.java b/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.java new file mode 100644 index 000000000000..ad6f07a0456b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.java @@ -0,0 +1,228 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2021 Chris Narkiewicz + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.network; + +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import com.nextcloud.client.account.Server; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.common.PlainClient; +import com.nextcloud.operations.GetMethod; +import com.owncloud.android.lib.common.utils.Log_OC; + +import org.apache.commons.httpclient.HttpStatus; + +import androidx.annotation.NonNull; +import androidx.core.net.ConnectivityManagerCompat; +import kotlin.jvm.functions.Function1; + +class ConnectivityServiceImpl implements ConnectivityService { + + private static final String TAG = "ConnectivityServiceImpl"; + private static final String CONNECTIVITY_CHECK_ROUTE = "/index.php/204"; + + private final ConnectivityManager platformConnectivityManager; + private final UserAccountManager accountManager; + private final ClientFactory clientFactory; + private final GetRequestBuilder requestBuilder; + private final WalledCheckCache walledCheckCache; + private final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); + + static class GetRequestBuilder implements Function1 { + @Override + public GetMethod invoke(String url) { + return new GetMethod(url, false); + } + } + + ConnectivityServiceImpl(ConnectivityManager platformConnectivityManager, + UserAccountManager accountManager, + ClientFactory clientFactory, + GetRequestBuilder requestBuilder, + final WalledCheckCache walledCheckCache) { + this.platformConnectivityManager = platformConnectivityManager; + this.accountManager = accountManager; + this.clientFactory = clientFactory; + this.requestBuilder = requestBuilder; + this.walledCheckCache = walledCheckCache; + } + + @Override + public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { + new Thread(() -> { + Network activeNetwork = platformConnectivityManager.getActiveNetwork(); + NetworkCapabilities networkCapabilities = platformConnectivityManager.getNetworkCapabilities(activeNetwork); + boolean hasInternet = networkCapabilities != null && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + + boolean result; + if (hasInternet) { + result = !isInternetWalled(); + } else { + Log_OC.e(TAG, "network and server not available"); + result = false; + } + + mainThreadHandler.post(() -> callback.onComplete(result)); + }).start(); + } + + @Override + public boolean isConnected() { + Network nw = platformConnectivityManager.getActiveNetwork(); + NetworkCapabilities actNw = platformConnectivityManager.getNetworkCapabilities(nw); + + if (actNw == null) { + Log_OC.e(TAG, "network capabilities is null"); + return false; + } + + if (actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || + actNw.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + actNw.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) || + actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) { + return true; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + actNw.hasTransport(NetworkCapabilities.TRANSPORT_USB)) { + return true; + } + + Log_OC.e(TAG, "network is not connected"); + return false; + } + + @Override + public boolean isInternetWalled() { + final Boolean cachedValue = walledCheckCache.getValue(); + if (cachedValue != null) { + if (cachedValue) { + Log_OC.e(TAG, "network is walled, cached value is used"); + } + + return cachedValue; + } else { + Server server = accountManager.getUser().getServer(); + String baseServerAddress = server.getUri().toString(); + + boolean result; + Connectivity c = getConnectivity(); + if (c != null && c.isConnected() && c.isWifi() && !c.isMetered() && !baseServerAddress.isEmpty()) { + Log_OC.d(TAG, "checking network status"); + + GetMethod get = requestBuilder.invoke(baseServerAddress + CONNECTIVITY_CHECK_ROUTE); + PlainClient client = clientFactory.createPlainClient(); + + int status = get.execute(client); + + // Content-Length is not available when using chunked transfer encoding, so check for -1 as well + result = !(status == HttpStatus.SC_NO_CONTENT && get.getResponseContentLength() <= 0); + get.releaseConnection(); + if (result) { + Log_OC.w(TAG, "isInternetWalled(): Failed to GET " + CONNECTIVITY_CHECK_ROUTE + "," + + " assuming connectivity is impaired"); + } + } else { + Log_OC.e(TAG, "cannot check network status, connectivity is not eligible"); + + if (c != null) { + if (c.isMetered()) { + Log_OC.e(TAG, "network is metered"); + } + + if (!c.isWifi()) { + Log_OC.e(TAG, "network is not connected to wi-fi"); + } + + if (!c.isConnected()) { + Log_OC.e(TAG, "network is not connected"); + } + } + + result = (c != null && !c.isConnected()); + } + + if (result) { + Log_OC.e(TAG, "network is walled"); + } + + walledCheckCache.setValue(result); + return result; + } + } + + @Override + public Connectivity getConnectivity() { + NetworkInfo networkInfo; + try { + networkInfo = platformConnectivityManager.getActiveNetworkInfo(); + } catch (Throwable t) { + Log_OC.e(TAG, "no network available or no information: ", t); + networkInfo = null; + } + + if (networkInfo != null) { + boolean isConnected = networkInfo.isConnectedOrConnecting(); + // more detailed check + boolean isMetered; + isMetered = isNetworkMetered(); + boolean isWifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI || hasNonCellularConnectivity(); + + if (isMetered) { + Log_OC.w(TAG, "getConnectivity(): network is metered"); + } + + if (!isWifi) { + Log_OC.w(TAG, "getConnectivity(): network is not wi-fi"); + } + + if (!isConnected) { + Log_OC.e(TAG, "getConnectivity(): network is not connected"); + } + + return new Connectivity(isConnected, isMetered, isWifi, null); + } else { + return Connectivity.DISCONNECTED; + } + } + + private boolean isNetworkMetered() { + final Network network = platformConnectivityManager.getActiveNetwork(); + try { + NetworkCapabilities networkCapabilities = platformConnectivityManager.getNetworkCapabilities(network); + if (networkCapabilities != null) { + return !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED); + } else { + return ConnectivityManagerCompat.isActiveNetworkMetered(platformConnectivityManager); + } + } catch (RuntimeException e) { + Log_OC.e(TAG, "Exception when checking network capabilities", e); + return false; + } + } + + private boolean hasNonCellularConnectivity() { + for (NetworkInfo networkInfo : platformConnectivityManager.getAllNetworkInfo()) { + if (networkInfo.isConnectedOrConnecting() && (networkInfo.getType() == ConnectivityManager.TYPE_WIFI || + networkInfo.getType() == ConnectivityManager.TYPE_ETHERNET)) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/NetworkModule.java b/app/src/main/java/com/nextcloud/client/network/NetworkModule.java new file mode 100644 index 000000000000..8fbca7e25162 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/NetworkModule.java @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.network; + +import android.content.Context; +import android.net.ConnectivityManager; + +import com.nextcloud.client.account.UserAccountManager; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class NetworkModule { + + @Provides + ConnectivityService connectivityService(ConnectivityManager connectivityManager, + UserAccountManager accountManager, + ClientFactory clientFactory, + WalledCheckCache walledCheckCache) { + return new ConnectivityServiceImpl(connectivityManager, + accountManager, + clientFactory, + new ConnectivityServiceImpl.GetRequestBuilder(), + walledCheckCache + ); + } + + @Provides + @Singleton + ClientFactory clientFactory(Context context) { + return new ClientFactoryImpl(context); + } + + @Provides + @Singleton + ConnectivityManager connectivityManager(Context context) { + return (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/WalledCheckCache.kt b/app/src/main/java/com/nextcloud/client/network/WalledCheckCache.kt new file mode 100644 index 000000000000..39599ab9f65b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/WalledCheckCache.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.network + +import com.nextcloud.client.core.Clock +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WalledCheckCache @Inject constructor(private val clock: Clock) { + + private var cachedEntry: Pair? = null + + @Synchronized + fun isExpired(): Boolean = when (val timestamp = cachedEntry?.first) { + null -> true + + else -> { + val diff = clock.currentTime - timestamp + diff >= CACHE_TIME_MS + } + } + + @Synchronized + fun setValue(isWalled: Boolean) { + this.cachedEntry = Pair(clock.currentTime, isWalled) + } + + @Synchronized + fun getValue(): Boolean? = when (isExpired()) { + true -> null + else -> cachedEntry?.second + } + + @Synchronized + fun clear() { + cachedEntry = null + } + + companion object { + // 10 minutes + private const val CACHE_TIME_MS = 10 * 60 * 1000 + } +} diff --git a/app/src/main/java/com/nextcloud/client/notifications/AppNotificationManager.kt b/app/src/main/java/com/nextcloud/client/notifications/AppNotificationManager.kt new file mode 100644 index 000000000000..6ee8e6ea3908 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/notifications/AppNotificationManager.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2021 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.notifications + +import android.app.Notification +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.OCFile + +/** + * Application-specific notification manager interface. + * Contrary to the platform [android.app.NotificationManager], + * it offer high-level, use-case oriented API. + */ +interface AppNotificationManager { + + companion object { + const val TRANSFER_NOTIFICATION_ID = 1_000_000 + } + + /** + * Builds notification to be set when downloader starts in foreground. + * + * @return foreground downloader service notification + */ + fun buildDownloadServiceForegroundNotification(): Notification + + /** + * Post download transfer progress notification. Subsequent calls will update + * currently displayed transfer notification. + * + * @param fileOwner User owning the downloaded file + * @param file File being downloaded + * @param progress Progress as percentage (0-100) + * @param allowPreview if true, pending intent with preview action is added to the notification + */ + fun postDownloadTransferProgress(fileOwner: User, file: OCFile, progress: Int, allowPreview: Boolean = true) + + /** + * Post upload transfer progress notification. Subsequent calls will update + * currently displayed transfer notification. + * + * @param fileOwner User owning the downloaded file + * @param file File being downloaded + * @param progress Progress as percentage (0-100) + */ + fun postUploadTransferProgress(fileOwner: User, file: OCFile, progress: Int) + + /** + * Removes download or upload progress notification. + */ + fun cancelTransferNotification() +} diff --git a/app/src/main/java/com/nextcloud/client/notifications/AppNotificationManagerImpl.kt b/app/src/main/java/com/nextcloud/client/notifications/AppNotificationManagerImpl.kt new file mode 100644 index 000000000000..2aed1d21e740 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/notifications/AppNotificationManagerImpl.kt @@ -0,0 +1,110 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2021 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.notifications + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.BitmapFactory +import androidx.core.app.NotificationCompat +import com.nextcloud.client.account.User +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.ui.preview.PreviewImageActivity +import com.owncloud.android.ui.preview.PreviewImageFragment +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class AppNotificationManagerImpl @Inject constructor( + private val context: Context, + private val resources: Resources, + private val platformNotificationsManager: NotificationManager, + private val viewThemeUtils: ViewThemeUtils +) : AppNotificationManager { + + companion object { + const val PROGRESS_PERCENTAGE_MAX = 100 + const val PROGRESS_PERCENTAGE_MIN = 0 + } + + private fun builder(channelId: String): NotificationCompat.Builder { + val builder = + NotificationCompat.Builder(context, channelId) + viewThemeUtils.androidx.themeNotificationCompatBuilder(context, builder) + return builder + } + + override fun buildDownloadServiceForegroundNotification(): Notification { + val icon = BitmapFactory.decodeResource(resources, R.drawable.notification_icon) + return builder(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD) + .setContentTitle(resources.getString(R.string.app_name)) + .setContentText(resources.getString(R.string.worker_download)) + .setSmallIcon(R.drawable.notification_icon) + .setLargeIcon(icon) + .build() + } + + override fun postDownloadTransferProgress(fileOwner: User, file: OCFile, progress: Int, allowPreview: Boolean) { + val builder = builder(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD) + val content = resources.getString( + R.string.downloader_download_in_progress_content, + progress, + file.fileName + ) + builder + .setSmallIcon(R.drawable.ic_cloud_download) + .setTicker(resources.getString(R.string.downloader_download_in_progress_ticker)) + .setContentTitle(resources.getString(R.string.downloader_download_in_progress_ticker)) + .setOngoing(true) + .setProgress(PROGRESS_PERCENTAGE_MAX, progress, progress <= PROGRESS_PERCENTAGE_MIN) + .setContentText(content) + + if (allowPreview) { + val openFileIntent = if (PreviewImageFragment.canBePreviewed(file)) { + PreviewImageActivity.previewFileIntent(context, fileOwner, file) + } else { + FileDisplayActivity.openFileIntent(context, fileOwner, file) + } + openFileIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + val pendingOpenFileIntent = PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + openFileIntent, + PendingIntent.FLAG_IMMUTABLE + ) + builder.setContentIntent(pendingOpenFileIntent) + } + platformNotificationsManager.notify(AppNotificationManager.TRANSFER_NOTIFICATION_ID, builder.build()) + } + + override fun postUploadTransferProgress(fileOwner: User, file: OCFile, progress: Int) { + val builder = builder(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD) + val content = resources.getString( + R.string.uploader_upload_in_progress_content, + progress, + file.fileName + ) + builder + .setSmallIcon(R.drawable.ic_cloud_upload) + .setTicker(resources.getString(R.string.uploader_upload_in_progress_ticker)) + .setContentTitle(resources.getString(R.string.uploader_upload_in_progress_ticker)) + .setOngoing(true) + .setProgress(PROGRESS_PERCENTAGE_MAX, progress, progress <= PROGRESS_PERCENTAGE_MIN) + .setContentText(content) + + platformNotificationsManager.notify(AppNotificationManager.TRANSFER_NOTIFICATION_ID, builder.build()) + } + + override fun cancelTransferNotification() { + platformNotificationsManager.cancel(AppNotificationManager.TRANSFER_NOTIFICATION_ID) + } +} diff --git a/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt new file mode 100644 index 000000000000..e7f7d987de16 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt @@ -0,0 +1,98 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.notifications + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.nextcloud.client.notifications.action.SyncConflictNotificationBroadcastReceiver +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.UploadListActivity +import com.owncloud.android.ui.notifications.NotificationUtils + +/** + * Responsible for showing **app-wide notifications** in the app. + * + * This manager provides a centralized place to create and display notifications + * that are not tied to a specific screen or feature. + * + */ +object AppWideNotificationManager { + + private const val TAG = "AppWideNotificationManager" + + private const val SYNC_CONFLICT_NOTIFICATION_INTENT_REQ_CODE = 16 + private const val SYNC_CONFLICT_NOTIFICATION_INTENT_ACTION_REQ_CODE = 17 + + private const val SYNC_CONFLICT_NOTIFICATION_ID = 112 + + fun getUploadListPendingIntent(context: Context): PendingIntent { + val intent = Intent(context, UploadListActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + return PendingIntent.getActivity( + context, + SYNC_CONFLICT_NOTIFICATION_INTENT_REQ_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + fun showSyncConflictNotification(context: Context) { + val pendingIntent = getUploadListPendingIntent(context) + + val actionIntent = Intent(context, SyncConflictNotificationBroadcastReceiver::class.java).apply { + putExtra(SyncConflictNotificationBroadcastReceiver.NOTIFICATION_ID, SYNC_CONFLICT_NOTIFICATION_ID) + } + + val actionPendingIntent = PendingIntent.getBroadcast( + context, + SYNC_CONFLICT_NOTIFICATION_INTENT_ACTION_REQ_CODE, + actionIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD) + .setSmallIcon(R.drawable.uploads) + .setContentTitle(context.getString(R.string.sync_conflict_notification_title)) + .setContentText(context.getString(R.string.sync_conflict_notification_description)) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.sync_conflict_notification_description)) + ) + .addAction( + R.drawable.ic_cloud_upload, + context.getString(R.string.sync_conflict_notification_action_title), + actionPendingIntent + ) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Log_OC.w(TAG, "cannot show sync conflict notification, post notification permission is not granted") + return + } + + NotificationManagerCompat.from(context) + .notify(SYNC_CONFLICT_NOTIFICATION_ID, notification) + } +} diff --git a/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt b/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt new file mode 100644 index 000000000000..db067a497b9e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.notifications.action + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.owncloud.android.ui.activity.UploadListActivity + +class SyncConflictNotificationBroadcastReceiver : BroadcastReceiver() { + companion object { + const val NOTIFICATION_ID = "NOTIFICATION_ID" + } + + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) + + if (notificationId != -1) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + + val intent = Intent(context, UploadListActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + context.startActivity(intent) + } +} diff --git a/app/src/main/java/com/nextcloud/client/onboarding/FirstRunActivity.kt b/app/src/main/java/com/nextcloud/client/onboarding/FirstRunActivity.kt new file mode 100644 index 000000000000..824e7b2d688c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/onboarding/FirstRunActivity.kt @@ -0,0 +1,256 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.onboarding + +import android.accounts.AccountManager +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.viewpager2.widget.ViewPager2 +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.appinfo.AppInfo +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.mdm.MDMConfig +import com.owncloud.android.BuildConfig +import com.owncloud.android.R +import com.owncloud.android.authentication.AuthenticatorActivity +import com.owncloud.android.databinding.FirstRunActivityBinding +import com.owncloud.android.features.FeatureItem +import com.owncloud.android.ui.activity.BaseActivity +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.adapter.FeaturesViewAdapter +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +/** + * Activity displaying general feature after a fresh install. + */ +class FirstRunActivity : + BaseActivity(), + Injectable { + + @JvmField + @Inject + var userAccountManager: UserAccountManager? = null + + @JvmField + @Inject + var preferences: AppPreferences? = null + + @JvmField + @Inject + var appInfo: AppInfo? = null + + @JvmField + @Inject + var onboarding: OnboardingService? = null + + @JvmField + @Inject + var viewThemeUtilsFactory: ViewThemeUtils.Factory? = null + + private var activityResult: ActivityResultLauncher? = null + + private lateinit var binding: FirstRunActivityBinding + private var defaultViewThemeUtils: ViewThemeUtils? = null + + override fun onCreate(savedInstanceState: Bundle?) { + enableAccountHandling = false + + super.onCreate(savedInstanceState) + + applyDefaultTheme() + + binding = FirstRunActivityBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSlideshowSize(resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) + + registerActivityResult() + setupLoginButton() + setupSignupButton(MDMConfig.showIntro(this)) + setupHostOwnServerTextView(MDMConfig.showIntro(this)) + deleteAccountAtFirstLaunch() + setupFeaturesViewAdapter() + handleOnBackPressed() + } + + private fun applyDefaultTheme() { + defaultViewThemeUtils = viewThemeUtilsFactory?.withPrimaryAsBackground() + defaultViewThemeUtils?.platform?.colorStatusBar(this, resources.getColor(R.color.primary)) + } + + private fun registerActivityResult() { + activityResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (RESULT_OK == result.resultCode) { + val data = result.data + val accountName = data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME) + val account = userAccountManager?.getAccountByName(accountName) + if (account == null) { + DisplayUtils.showSnackMessage(this, R.string.account_creation_failed) + return@registerForActivityResult + } + + userAccountManager?.setCurrentOwnCloudAccount(account.name) + + val i = Intent(this, FileDisplayActivity::class.java) + i.action = FileDisplayActivity.RESTART + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(i) + finish() + } + } + } + + private fun setupLoginButton() { + defaultViewThemeUtils?.material?.colorMaterialButtonFilledOnPrimary(binding.login) + binding.login.setOnClickListener { + if (intent.getBooleanExtra(EXTRA_ALLOW_CLOSE, false)) { + val authenticatorActivityIntent = getAuthenticatorActivityIntent(false) + activityResult?.launch(authenticatorActivityIntent) + } else { + finish() + } + } + } + + private fun setupSignupButton(isProviderOrOwnInstallationVisible: Boolean) { + defaultViewThemeUtils?.material?.colorMaterialButtonOutlinedOnPrimary(binding.signup) + binding.signup.visibility = if (isProviderOrOwnInstallationVisible) View.VISIBLE else View.GONE + binding.signup.setOnClickListener { + val authenticatorActivityIntent = getAuthenticatorActivityIntent(true) + + if (intent.getBooleanExtra(EXTRA_ALLOW_CLOSE, false)) { + activityResult?.launch(authenticatorActivityIntent) + } else { + authenticatorActivityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(authenticatorActivityIntent) + } + } + } + + private fun getAuthenticatorActivityIntent(extraUseProviderAsWebLogin: Boolean): Intent { + val intent = Intent(this, AuthenticatorActivity::class.java) + intent.putExtra(AuthenticatorActivity.EXTRA_USE_PROVIDER_AS_WEBLOGIN, extraUseProviderAsWebLogin) + return intent + } + + private fun setupHostOwnServerTextView(isProviderOrOwnInstallationVisible: Boolean) { + defaultViewThemeUtils?.platform?.colorTextView(binding.hostOwnServer, ColorRole.ON_PRIMARY) + binding.hostOwnServer.visibility = if (isProviderOrOwnInstallationVisible) View.VISIBLE else View.GONE + if (isProviderOrOwnInstallationVisible) { + binding.hostOwnServer.setOnClickListener { + DisplayUtils.startLinkIntent( + this, + R.string.url_server_install + ) + } + } + } + + // Sometimes, accounts are not deleted when you uninstall the application so we'll do it now + private fun deleteAccountAtFirstLaunch() { + if (onboarding?.isFirstRun == true) { + userAccountManager?.removeAllAccounts() + } + } + + @Suppress("SpreadOperator") + private fun setupFeaturesViewAdapter() { + val featuresViewAdapter = FeaturesViewAdapter(this, *firstRun) + binding.progressIndicator.setNumberOfSteps(featuresViewAdapter.itemCount) + binding.contentPanel.adapter = featuresViewAdapter + binding.contentPanel.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + binding.progressIndicator.animateToStep(position + 1) + } + }) + } + + private fun handleOnBackPressed() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val isFromAddAccount = intent.getBooleanExtra(EXTRA_ALLOW_CLOSE, false) + + val destination: Intent = if (isFromAddAccount) { + Intent(applicationContext, FileDisplayActivity::class.java) + } else { + Intent(applicationContext, AuthenticatorActivity::class.java) + } + + if (!isFromAddAccount) { + destination.putExtra(EXTRA_EXIT, true) + } + + destination.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + startActivity(destination) + finish() + } + } + ) + } + + private fun setSlideshowSize(isLandscape: Boolean) { + binding.buttonLayout.orientation = if (isLandscape) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL + + val layoutParams: LinearLayout.LayoutParams = if (MDMConfig.showIntro(this)) { + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } else { + @Suppress("MagicNumber") + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + DisplayUtils.convertDpToPixel(if (isLandscape) 100f else 150f, this) + ) + } + + binding.bottomLayout.layoutParams = layoutParams + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + setSlideshowSize(newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) + } + + private fun onFinish() { + preferences?.lastSeenVersionCode = BuildConfig.VERSION_CODE + } + + override fun onStop() { + onFinish() + super.onStop() + } + + companion object { + const val EXTRA_ALLOW_CLOSE = "ALLOW_CLOSE" + const val EXTRA_EXIT = "EXIT" + + val firstRun: Array + get() = arrayOf( + FeatureItem(R.drawable.logo, R.string.first_run_1_text, R.string.empty, true, false), + FeatureItem(R.drawable.first_run_files, R.string.first_run_2_text, R.string.empty, true, false), + FeatureItem(R.drawable.first_run_groupware, R.string.first_run_3_text, R.string.empty, true, false), + FeatureItem(R.drawable.first_run_talk, R.string.first_run_4_text, R.string.empty, true, false) + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/onboarding/OnboardingModule.kt b/app/src/main/java/com/nextcloud/client/onboarding/OnboardingModule.kt new file mode 100644 index 000000000000..b97382fdc4de --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/onboarding/OnboardingModule.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.onboarding + +import android.content.res.Resources +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.preferences.AppPreferences +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +class OnboardingModule { + + @Provides + @Singleton + internal fun onboardingService( + resources: Resources, + preferences: AppPreferences, + accountProvider: CurrentAccountProvider + ): OnboardingService = OnboardingServiceImpl(resources, preferences, accountProvider) +} diff --git a/app/src/main/java/com/nextcloud/client/onboarding/OnboardingService.kt b/app/src/main/java/com/nextcloud/client/onboarding/OnboardingService.kt new file mode 100644 index 000000000000..e75955ffe0e1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/onboarding/OnboardingService.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.onboarding + +import android.app.Activity +import android.content.Context +import com.owncloud.android.features.FeatureItem + +interface OnboardingService { + val whatsNew: Array + val isFirstRun: Boolean + fun launchActivityIfNeeded(activity: Activity) + fun launchFirstRunIfNeeded(activity: Activity): Boolean + fun shouldShowWhatsNew(callingContext: Context): Boolean +} diff --git a/app/src/main/java/com/nextcloud/client/onboarding/OnboardingServiceImpl.kt b/app/src/main/java/com/nextcloud/client/onboarding/OnboardingServiceImpl.kt new file mode 100644 index 000000000000..ceaf9113e59e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/onboarding/OnboardingServiceImpl.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.onboarding + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.mdm.MDMConfig +import com.owncloud.android.BuildConfig +import com.owncloud.android.R +import com.owncloud.android.authentication.AuthenticatorActivity +import com.owncloud.android.features.FeatureItem +import com.owncloud.android.ui.activity.PassCodeActivity + +internal class OnboardingServiceImpl( + private val resources: Resources, + private val preferences: AppPreferences, + private val accountProvider: CurrentAccountProvider +) : OnboardingService { + + private companion object { + const val ITEM_VERSION_CODE = 99999999 + } + + private val notSeenYet: Boolean + get() { + return BuildConfig.VERSION_CODE >= ITEM_VERSION_CODE && preferences.lastSeenVersionCode < ITEM_VERSION_CODE + } + + override val whatsNew: Array + get() = if (!isFirstRun && notSeenYet) { + emptyArray() + } else { + emptyArray() + } + + override val isFirstRun: Boolean + get() { + return accountProvider.user.isAnonymous + } + + override fun shouldShowWhatsNew(callingContext: Context): Boolean = + callingContext !is PassCodeActivity && whatsNew.isNotEmpty() + + override fun launchActivityIfNeeded(activity: Activity) { + if (!resources.getBoolean(R.bool.show_whats_new) || activity is WhatsNewActivity) { + return + } + + if (shouldShowWhatsNew(activity)) { + activity.startActivity(Intent(activity, WhatsNewActivity::class.java)) + } + } + + override fun launchFirstRunIfNeeded(activity: Activity): Boolean { + val canLaunch = MDMConfig.showIntro(activity) && isFirstRun && activity is AuthenticatorActivity + if (canLaunch) { + val intent = Intent(activity, FirstRunActivity::class.java) + activity.startActivityForResult(intent, AuthenticatorActivity.REQUEST_CODE_FIRST_RUN) + } + return canLaunch + } +} diff --git a/app/src/main/java/com/nextcloud/client/onboarding/WhatsNewActivity.kt b/app/src/main/java/com/nextcloud/client/onboarding/WhatsNewActivity.kt new file mode 100644 index 000000000000..dd51319fc54f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/onboarding/WhatsNewActivity.kt @@ -0,0 +1,156 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.onboarding + +import android.os.Bundle +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.appinfo.AppInfo +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.BuildConfig +import com.owncloud.android.R +import com.owncloud.android.databinding.WhatsNewActivityBinding +import com.owncloud.android.ui.adapter.FeaturesViewAdapter +import com.owncloud.android.ui.adapter.FeaturesWebViewAdapter +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +/** + * Activity displaying new features after an update. + */ +class WhatsNewActivity : + FragmentActivity(), + Injectable { + + @JvmField + @Inject + var preferences: AppPreferences? = null + + @JvmField + @Inject + var appInfo: AppInfo? = null + + @JvmField + @Inject + var onboarding: OnboardingService? = null + + @JvmField + @Inject + var viewThemeUtilsFactory: ViewThemeUtils.Factory? = null + + private var viewThemeUtils: ViewThemeUtils? = null + + private lateinit var binding: WhatsNewActivityBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = WhatsNewActivityBinding.inflate(layoutInflater) + setContentView(binding.root) + + viewThemeUtils = viewThemeUtilsFactory?.withPrimaryAsBackground() + viewThemeUtils?.platform?.themeStatusBar(this, ColorRole.PRIMARY) + + val urls = resources.getStringArray(R.array.whatsnew_urls) + val showWebView = urls.isNotEmpty() + + setupFeatureViewAdapter(showWebView, urls) + binding.contentPanel.registerOnPageChangeCallback(object : OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + controlPanelOnPageSelected(position) + } + }) + setupForwardImageButton() + setupSkipImageButton() + setupWelcomeText(showWebView) + updateNextButtonIfNeeded() + handleOnBackPressed() + } + + @Suppress("SpreadOperator") + private fun setupFeatureViewAdapter(showWebView: Boolean, urls: Array) { + val adapter = if (showWebView) { + FeaturesWebViewAdapter(this, *urls) + } else { + onboarding?.let { + FeaturesViewAdapter(this, *it.whatsNew) + } + } + + adapter?.let { + binding.progressIndicator.setNumberOfSteps(it.itemCount) + binding.contentPanel.adapter = it + } + } + + private fun setupForwardImageButton() { + viewThemeUtils?.platform?.colorImageView(binding.forward, ColorRole.ON_PRIMARY) + binding.forward.setOnClickListener { + if (binding.progressIndicator.hasNextStep()) { + binding.contentPanel.setCurrentItem(binding.contentPanel.currentItem + 1, true) + binding.progressIndicator.animateToStep(binding.contentPanel.currentItem + 1) + } else { + onFinish() + finish() + } + updateNextButtonIfNeeded() + } + binding.forward.background = null + } + + private fun setupSkipImageButton() { + viewThemeUtils?.platform?.colorTextView(binding.skip, ColorRole.ON_PRIMARY) + binding.skip.setOnClickListener { + onFinish() + finish() + } + } + + private fun setupWelcomeText(showWebView: Boolean) { + viewThemeUtils?.platform?.colorTextView(binding.welcomeText, ColorRole.ON_PRIMARY) + binding.welcomeText.text = if (showWebView) { + getString(R.string.app_name) + } else { + String.format(getString(R.string.whats_new_title), appInfo?.versionName) + } + } + + private fun handleOnBackPressed() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onFinish() + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + ) + } + + private fun updateNextButtonIfNeeded() { + val hasNextStep = binding.progressIndicator.hasNextStep() + binding.forward.setImageResource(if (hasNextStep) R.drawable.arrow_right else R.drawable.ic_ok) + binding.skip.visibility = if (hasNextStep) View.VISIBLE else View.INVISIBLE + } + + private fun onFinish() { + preferences?.lastSeenVersionCode = BuildConfig.VERSION_CODE + } + + private fun controlPanelOnPageSelected(position: Int) { + binding.progressIndicator.animateToStep(position + 1) + updateNextButtonIfNeeded() + } +} diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java new file mode 100644 index 000000000000..b7d6818ea5a1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java @@ -0,0 +1,402 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Jonas Mayer + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: GPL-3.0-or-later AND AGPL-3.0-or-later + */ +package com.nextcloud.client.preferences; + +import com.nextcloud.appReview.AppReviewShownModel; +import com.nextcloud.client.jobs.LogEntry; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.utils.FileSortOrder; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * This interface provides single point of entry for access to all application + * preferences and allows clients to subscribe for specific configuration changes. + */ +public interface AppPreferences { + + /** + * Preferences listener. Callbacks should be invoked on main thread. + * Maintainers should extend this interface with callbacks for specific events. + */ + interface Listener { + default void onDarkThemeModeChanged(DarkMode mode) { + /* default empty implementation */ + }; + } + + /** + * Registers preferences listener. It no-ops if listener + * is already registered. + * + * @param listener application preferences listener + */ + void addListener(@Nullable Listener listener); + + /** + * Unregister listener. It no-ops if listener is not registered. + * + * @param listener application preferences listener + */ + void removeListener(@Nullable Listener listener); + + void setKeysReInitEnabled(); + boolean isKeysReInitEnabled(); + + void setPushToken(String pushToken); + String getPushToken(); + + boolean instantPictureUploadEnabled(); + boolean instantVideoUploadEnabled(); + boolean isDarkModeEnabled(); + + boolean isShowHiddenFilesEnabled(); + void setShowHiddenFilesEnabled(boolean enabled); + + boolean isSortFoldersBeforeFiles(); + void setSortFoldersBeforeFiles(boolean enabled); + + boolean isSortFavoritesFirst(); + void setSortFavoritesFirst(boolean enabled); + + boolean isShowEcosystemApps(); + void setShowEcosystemApps(boolean enabled); + + /** + * Gets the selected file extension position the user selected to do the + * last upload of a url file shared from other app. + * + * @return selectedPos the selected file extension position. + */ + int getUploadUrlFileExtensionUrlSelectedPos(); + + /** + * Saves the selected file extension position the user selected to do the + * last upload of a url file shared from other app. + * + * @param selectedPos the selected file extension position. + */ + void setUploadUrlFileExtensionUrlSelectedPos(int selectedPos); + + /** + * Gets the selected map file extension position the user selected to + * do the last upload of a url file shared from other app. + * + * @return selectedPos the selected file extension position. + */ + int getUploadMapFileExtensionUrlSelectedPos(); + + /** + * Saves the selected map file extension position the user selected to + * do the last upload of a url file shared from other app. + * + * @param selectedPos the selected file extension position. + */ + void setUploadMapFileExtensionUrlSelectedPos(int selectedPos); + + /** + * Gets the last local path where the user selected to do an upload from. + * + * @return path Absolute path to a folder, as previously stored by + * {@link #setUploadFromLocalLastPath(String)}, or empty String if never saved before. + */ + String getUploadFromLocalLastPath(); + + /** + * Saves the path where the user selected to do the last local upload of a file from. + * + * @param path Absolute path to a folder. + */ + void setUploadFromLocalLastPath(String path); + + /** + * Gets the path where the user selected to do the last upload of a file shared from other app. + * + * @return path Absolute path to a folder, as previously stored by {@link #setLastUploadPath(String)}, + * or empty String if never saved before. + */ + String getLastUploadPath(); + + /** + * Get preferred folder display type. + * + * @param folder Folder + * @return preference value, default is + * {@link com.owncloud.android.ui.fragment.OCFileListFragment#FOLDER_LAYOUT_LIST} + */ + String getFolderLayout(OCFile folder); + + /** + * Set preferred folder display type. + * + * @param folder Folder which layout is being set or null for root folder + * @param layoutName preference value + */ + void setFolderLayout(@Nullable OCFile folder, String layoutName); + + /** + * Saves the path where the user selected to do the last upload of a file shared from other app. + * + * @param path Absolute path to a folder. + */ + void setLastUploadPath(String path); + + String getLockPreference(); + void setLockPreference(String lockPreference); + + /** + * Set pass code composed of 4 digits (as strings). + * + * @todo This must be refactored further to use a passcode stype + * @param d1 1st digit + * @param d2 2nd digit + * @param d3 3rd digit + * @param d4 4th digit + */ + void setPassCode(String d1, String d2, String d3, String d4); + + /** + * Get 4-digit passcode as array of strings. Strings may be null. + * + * @return 4 strings with digits or nulls + */ + String[] getPassCode(); + + /** + * Gets the auto upload paths flag last set. + * + * @return ascending order the legacy cleaning flag, default is false + */ + boolean isAutoUploadPathsUpdateEnabled(); + + /** + * Saves the legacy cleaning flag which the user has set last. + * + * @param pathUpdate flag if it is a auto upload path update + */ + void setAutoUploadPathsUpdateEnabled(boolean pathUpdate); + + /** + * Gets the auto upload split out flag last set. + * + * @return ascending order the legacy cleaning flag, default is false + */ + boolean isAutoUploadSplitEntriesEnabled(); + + /** + * Saves the flag for split entries magic + * + * @param splitOut flag if it is a auto upload path update + */ + void setAutoUploadSplitEntriesEnabled(boolean splitOut); + + boolean isAutoUploadInitialized(); + void setAutoUploadInit(boolean autoUploadInit); + + /** + * Get preferred folder sort order. + * + * @param folder Folder whoch order is being retrieved or null for root folder + * @return sort order the sort order, default is {@link FileSortOrder# sort_a_to_z} (sort by name) + */ + FileSortOrder getSortOrderByFolder(@Nullable OCFile folder); + + /** + * Set preferred folder sort order. + * + * @param folder Folder which sort order is changed; if null, root folder is assumed + * @param sortOrder the sort order of a folder + */ + void setSortOrder(@Nullable OCFile folder, FileSortOrder sortOrder); + + /** + * Set preferred folder sort order. + * + * @param sortOrder the sort order + */ + void setSortOrder(FileSortOrder.Type type, FileSortOrder sortOrder); + + /** + * Get preferred folder sort order. + * + * @return sort order the sort order, default is {@link FileSortOrder# sort_a_to_z} (sort by name) + */ + FileSortOrder getSortOrderByType(FileSortOrder.Type type, FileSortOrder defaultOrder); + FileSortOrder getSortOrderByType(FileSortOrder.Type type); + + + /** + * Gets the legacy cleaning flag last set. + * + * @return ascending order the legacy cleaning flag, default is false + */ + boolean isLegacyClean(); + + /** + * Saves the legacy cleaning flag which the user has set last. + * + * @param legacyClean flag if it is a legacy cleaning + */ + void setLegacyClean(boolean legacyClean); + + boolean isKeysMigrationEnabled(); + void setKeysMigrationEnabled(boolean enabled); + + boolean isStoragePathFixEnabled(); + void setStoragePathFixEnabled(boolean enabled); + + boolean isShowDetailedTimestampEnabled(); + void setShowDetailedTimestampEnabled(boolean showDetailedTimestamp); + + boolean isShowMediaScanNotifications(); + void setShowMediaScanNotifications(boolean showMediaScanNotification); + + /** + * Gets the uploader behavior which the user has set last. + * + * @return uploader behavior the uploader behavior + */ + int getUploaderBehaviour(); + + /** + * Changes dark theme mode + * + * This is reactive property. Listeners will be invoked if registered. + * + * @param mode dark mode setting: on, off, system + */ + void setDarkThemeMode(DarkMode mode); + + /** + * @return dark mode setting: on, off, system + */ + DarkMode getDarkThemeMode(); + + /** + * Saves the uploader behavior which the user has set last. + * + * @param uploaderBehaviour the uploader behavior + */ + void setUploaderBehaviour(int uploaderBehaviour); + + float getGridColumns(); + void setGridColumns(float gridColumns); + + long getLockTimestamp(); + void setLockTimestamp(long timestamp); + + /** + * Gets the last seen version code right before updating. + * + * @return grid columns grid columns + */ + int getLastSeenVersionCode(); + + void saveLogEntry(List logEntryList); + + List readLogEntry(); + + /** + * Saves the version code as the last seen version code. + * + * @param versionCode the app's version code + */ + void setLastSeenVersionCode(int versionCode); + + void removeLegacyPreferences(); + + /** + * Clears all user preferences. + * + * @implNote this clears only shared preferences, not preferences kept in account manager + */ + void clear(); + + String getStoragePath(String defaultPath); + + void setStoragePath(String path); + + void setStoragePathValid(); + + boolean isStoragePathValid(); + + void removeKeysMigrationPreference(); + + String getCurrentAccountName(); + + void setCurrentAccountName(String accountName); + + /** + * Gets status of migration to user id, default false + * + * @return true: migration done: every account has userId, false: pending accounts without userId + */ + boolean isUserIdMigrated(); + + void setMigratedUserId(boolean value); + + void setPhotoSearchTimestamp(long timestamp); + + long getPhotoSearchTimestamp(); + + void increasePinWrongAttempts(); + + void resetPinWrongAttempts(); + + int pinBruteForceDelay(); + + String getUidPid(); + + void setUidPid(String uidPid); + + long getCalendarLastBackup(); + + void setCalendarLastBackup(long timestamp); + + boolean isGlobalUploadPaused(); + + void setGlobalUploadPaused(boolean globalPausedState); + + boolean isStoragePermissionRequested(); + + void setStoragePermissionRequested(boolean value); + + void setInAppReviewData(@NonNull AppReviewShownModel appReviewShownModel); + + @Nullable + AppReviewShownModel getInAppReviewData(); + + void setLastSelectedMediaFolder(@NonNull String path); + + @NonNull + String getLastSelectedMediaFolder(); + + void setTwoWaySyncStatus(boolean value); + boolean isTwoWaySyncEnabled(); + + void setTwoWaySyncInterval(Long value); + Long getTwoWaySyncInterval(); + + boolean shouldStopDownloadJobsOnStart(); + void setStopDownloadJobsOnStart(boolean value); + + int getPassCodeDelay(); + void setPassCodeDelay(int value); + + String getLastDisplayedAccountName(); + void setLastDisplayedAccountName(String lastDisplayedAccountName); + + boolean startAutoUploadOnStart(); + void setLastAutoUploadOnStartTime(long timeInMillisecond); +} diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java new file mode 100644 index 000000000000..a16beaeaaad9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -0,0 +1,870 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.preferences; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; + +import com.google.common.reflect.TypeToken; +import com.google.gson.Gson; +import com.nextcloud.appReview.AppReviewShownModel; +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.account.UserAccountManagerImpl; +import com.nextcloud.client.jobs.LogEntry; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.ui.activity.PassCodeActivity; +import com.owncloud.android.ui.activity.SettingsActivity; +import com.owncloud.android.utils.FileSortOrder; + +import java.lang.reflect.Type; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import static com.owncloud.android.ui.fragment.OCFileListFragment.FOLDER_LAYOUT_LIST; +import static java.util.Collections.emptyList; + +/** + * Implementation of application-wide preferences using {@link SharedPreferences}. + *

+ * Users should not use this class directly. Please use {@link AppPreferences} interface instead. + */ +public final class AppPreferencesImpl implements AppPreferences { + + /** + * Constant to access value of last path selected by the user to upload a file shared from other app. Value handled + * by the app without direct access in the UI. + */ + public static final String AUTO_PREF__LAST_SEEN_VERSION_CODE = "lastSeenVersionCode"; + public static final String STORAGE_PATH = "storage_path"; + public static final String DATA_STORAGE_LOCATION = "data_storage_location"; + public static final String STORAGE_PATH_VALID = "storage_path_valid"; + public static final String PREF__DARK_THEME = "dark_theme_mode"; + public static final float DEFAULT_GRID_COLUMN = 3f; + + private static final String AUTO_PREF__LAST_UPLOAD_PATH = "last_upload_path"; + private static final String AUTO_PREF__UPLOAD_FROM_LOCAL_LAST_PATH = "upload_from_local_last_path"; + private static final String AUTO_PREF__UPLOAD_FILE_EXTENSION_MAP_URL = "prefs_upload_file_extension_map_url"; + private static final String AUTO_PREF__UPLOAD_FILE_EXTENSION_URL = "prefs_upload_file_extension_url"; + private static final String AUTO_PREF__UPLOADER_BEHAVIOR = "prefs_uploader_behaviour"; + private static final String AUTO_PREF__GRID_COLUMNS = "grid_columns"; + private static final String AUTO_PREF__SHOW_DETAILED_TIMESTAMP = "detailed_timestamp"; + private static final String PREF__INSTANT_UPLOADING = "instant_uploading"; + private static final String PREF__INSTANT_VIDEO_UPLOADING = "instant_video_uploading"; + private static final String PREF__SHOW_HIDDEN_FILES = "show_hidden_files_pref"; + private static final String PREF__SORT_FOLDERS_BEFORE_FILES = "sort_folders_before_files"; + private static final String PREF__SORT_FAVORITES_FIRST = "sort_favorites_first"; + private static final String PREF__SHOW_ECOSYSTEM_APPS = "show_ecosystem_apps"; + private static final String PREF__LEGACY_CLEAN = "legacyClean"; + private static final String PREF__KEYS_MIGRATION = "keysMigration"; + private static final String PREF__FIX_STORAGE_PATH = "storagePathFix"; + private static final String PREF__KEYS_REINIT = "keysReinit"; + private static final String PREF__AUTO_UPLOAD_UPDATE_PATH = "autoUploadPathUpdate"; + private static final String PREF__PUSH_TOKEN = "pushToken"; + private static final String PREF__AUTO_UPLOAD_SPLIT_OUT = "autoUploadEntriesSplitOut"; + private static final String PREF__AUTO_UPLOAD_INIT = "autoUploadInit"; + private static final String PREF__FOLDER_SORT_ORDER = "folder_sort_order"; + private static final String PREF__FOLDER_LAYOUT = "folder_layout"; + + private static final String PREF__LOCK_TIMESTAMP = "lock_timestamp"; + private static final String PREF__SHOW_MEDIA_SCAN_NOTIFICATIONS = "show_media_scan_notifications"; + private static final String PREF__LOCK = SettingsActivity.PREFERENCE_LOCK; + private static final String PREF__SELECTED_ACCOUNT_NAME = "select_oc_account"; + private static final String PREF__MIGRATED_USER_ID = "migrated_user_id"; + private static final String PREF__PHOTO_SEARCH_TIMESTAMP = "photo_search_timestamp"; + private static final String PREF__PIN_BRUTE_FORCE_COUNT = "pin_brute_force_count"; + private static final String PREF__UID_PID = "uid_pid"; + + private static final String PREF__CALENDAR_AUTOMATIC_BACKUP = "calendar_automatic_backup"; + private static final String PREF__CALENDAR_LAST_BACKUP = "calendar_last_backup"; + + private static final String PREF__GLOBAL_PAUSE_STATE = "global_pause_state"; + + private static final String PREF__MEDIA_FOLDER_LAST_PATH = "media_folder_last_path"; + private static final String PREF__STORAGE_PERMISSION_REQUESTED = "storage_permission_requested"; + private static final String PREF__IN_APP_REVIEW_DATA = "in_app_review_data"; + + private static final String PREF__TWO_WAY_STATUS = "two_way_sync_status"; + private static final String PREF__TWO_WAY_SYNC_INTERVAL = "two_way_sync_interval"; + + private static final String PREF__STOP_DOWNLOAD_JOBS_ON_START = "stop_download_jobs_on_start"; + + private static final String PREF__PASSCODE_DELAY_IN_SECONDS = "passcode_delay_in_seconds"; + + private static final String PREF_LAST_DISPLAYED_ACCOUNT_NAME = "last_displayed_user"; + + private static final String AUTO_PREF__LAST_AUTO_UPLOAD_ON_START = "last_auto_upload_on_start"; + + + private static final String LOG_ENTRY = "log_entry"; + + private final Context context; + private final SharedPreferences preferences; + private final UserAccountManager userAccountManager; + private final ListenerRegistry listeners; + + private static final int AUTO_UPLOAD_ON_START_DEBOUNCE_IN_MINUTES = 10; + private static final long AUTO_UPLOAD_ON_START_DEBOUNCE_MS = AUTO_UPLOAD_ON_START_DEBOUNCE_IN_MINUTES * 60 * 1000L; + + /** + * Adapter delegating raw {@link SharedPreferences.OnSharedPreferenceChangeListener} calls with key-value pairs to + * respective {@link com.nextcloud.client.preferences.AppPreferences.Listener} method. + */ + static class ListenerRegistry implements SharedPreferences.OnSharedPreferenceChangeListener { + private final AppPreferences preferences; + private final Set listeners; + + ListenerRegistry(AppPreferences preferences) { + this.preferences = preferences; + this.listeners = new CopyOnWriteArraySet<>(); + } + + void add(@Nullable final Listener listener) { + if (listener != null) { + listeners.add(listener); + } + } + + void remove(@Nullable final Listener listener) { + if (listener != null) { + listeners.remove(listener); + } + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (PREF__DARK_THEME.equals(key)) { + DarkMode mode = preferences.getDarkThemeMode(); + for (Listener l : listeners) { + l.onDarkThemeModeChanged(mode); + } + } + } + } + + /** + * This is a temporary workaround to access app preferences in places that cannot use dependency injection yet. Use + * injected component via {@link AppPreferences} interface. + *

+ * WARNING: this creates new instance! it does not return app-wide singleton + * + * @param context Context used to create shared preferences + * @return New instance of app preferences component + */ + @Deprecated + public static AppPreferences fromContext(Context context) { + final UserAccountManager userAccountManager = UserAccountManagerImpl.fromContext(context); + final SharedPreferences prefs = android.preference.PreferenceManager.getDefaultSharedPreferences(context); + return new AppPreferencesImpl(context, prefs, userAccountManager); + } + + AppPreferencesImpl(Context appContext, SharedPreferences preferences, UserAccountManager userAccountManager) { + this.context = appContext; + this.preferences = preferences; + this.userAccountManager = userAccountManager; + this.listeners = new ListenerRegistry(this); + this.preferences.registerOnSharedPreferenceChangeListener(listeners); + } + + @Override + public void addListener(@Nullable AppPreferences.Listener listener) { + this.listeners.add(listener); + } + + @Override + public void removeListener(@Nullable AppPreferences.Listener listener) { + this.listeners.remove(listener); + } + + @Override + public void setKeysReInitEnabled() { + preferences.edit().putBoolean(PREF__KEYS_REINIT, true).apply(); + } + + @Override + public boolean isKeysReInitEnabled() { + return preferences.getBoolean(PREF__KEYS_REINIT, false); + } + + @Override + public void setPushToken(String pushToken) { + preferences.edit().putString(PREF__PUSH_TOKEN, pushToken).apply(); + } + + @Override + public String getPushToken() { + return preferences.getString(PREF__PUSH_TOKEN, ""); + } + + @Override + public boolean instantPictureUploadEnabled() { + return preferences.getBoolean(PREF__INSTANT_UPLOADING, false); + } + + @Override + public boolean instantVideoUploadEnabled() { + return preferences.getBoolean(PREF__INSTANT_VIDEO_UPLOADING, false); + } + + @Override + public boolean isShowHiddenFilesEnabled() { + return preferences.getBoolean(PREF__SHOW_HIDDEN_FILES, false); + } + + @Override + public void setShowHiddenFilesEnabled(boolean enabled) { + preferences.edit().putBoolean(PREF__SHOW_HIDDEN_FILES, enabled).apply(); + } + + @Override + public boolean isSortFoldersBeforeFiles() { + return preferences.getBoolean(PREF__SORT_FOLDERS_BEFORE_FILES, true); + } + + @Override + public void setSortFoldersBeforeFiles(boolean enabled) { + preferences.edit().putBoolean(PREF__SORT_FOLDERS_BEFORE_FILES, enabled).apply(); + } + + @Override + public boolean isSortFavoritesFirst() { + return preferences.getBoolean(PREF__SORT_FAVORITES_FIRST, true); + } + + @Override + public void setSortFavoritesFirst(boolean enabled) { + preferences.edit().putBoolean(PREF__SORT_FAVORITES_FIRST, enabled).apply(); + } + + @Override + public boolean isShowEcosystemApps() { + return preferences.getBoolean(PREF__SHOW_ECOSYSTEM_APPS, true); + } + + @Override + public void setShowEcosystemApps(boolean enabled) { + preferences.edit().putBoolean(PREF__SHOW_ECOSYSTEM_APPS, enabled).apply(); + } + + @Override + public int getUploadUrlFileExtensionUrlSelectedPos() { + return preferences.getInt(AUTO_PREF__UPLOAD_FILE_EXTENSION_URL, 0); + } + + @Override + public void setUploadUrlFileExtensionUrlSelectedPos(int selectedPos) { + preferences.edit().putInt(AUTO_PREF__UPLOAD_FILE_EXTENSION_URL, selectedPos).apply(); + } + + @Override + public int getUploadMapFileExtensionUrlSelectedPos() { + return preferences.getInt(AUTO_PREF__UPLOAD_FILE_EXTENSION_MAP_URL, 0); + } + + @Override + public void setUploadMapFileExtensionUrlSelectedPos(int selectedPos) { + preferences.edit().putInt(AUTO_PREF__UPLOAD_FILE_EXTENSION_MAP_URL, selectedPos).apply(); + } + + @Override + public String getUploadFromLocalLastPath() { + return preferences.getString(AUTO_PREF__UPLOAD_FROM_LOCAL_LAST_PATH, ""); + } + + @Override + public void setUploadFromLocalLastPath(String path) { + preferences.edit().putString(AUTO_PREF__UPLOAD_FROM_LOCAL_LAST_PATH, path).apply(); + } + + @Override + public String getLastUploadPath() { + return preferences.getString(AUTO_PREF__LAST_UPLOAD_PATH, ""); + } + + @Override + public void setLastUploadPath(String path) { + preferences.edit().putString(AUTO_PREF__LAST_UPLOAD_PATH, path).apply(); + } + + @Override + public String getLockPreference() { + return preferences.getString(PREF__LOCK, SettingsActivity.LOCK_NONE); + } + + @Override + public void setLockPreference(String lockPreference) { + preferences.edit().putString(PREF__LOCK, lockPreference).apply(); + } + + @Override + public void setPassCode(String d1, String d2, String d3, String d4) { + preferences + .edit() + .putString(PassCodeActivity.PREFERENCE_PASSCODE_D1, d1) + .putString(PassCodeActivity.PREFERENCE_PASSCODE_D2, d2) + .putString(PassCodeActivity.PREFERENCE_PASSCODE_D3, d3) + .putString(PassCodeActivity.PREFERENCE_PASSCODE_D4, d4) + .apply(); + } + + @Override + public String[] getPassCode() { + return new String[]{ + preferences.getString(PassCodeActivity.PREFERENCE_PASSCODE_D1, null), + preferences.getString(PassCodeActivity.PREFERENCE_PASSCODE_D2, null), + preferences.getString(PassCodeActivity.PREFERENCE_PASSCODE_D3, null), + preferences.getString(PassCodeActivity.PREFERENCE_PASSCODE_D4, null), + }; + } + + @Override + public String getFolderLayout(OCFile folder) { + return getFolderPreference(context, + userAccountManager.getUser(), + PREF__FOLDER_LAYOUT, + folder, + FOLDER_LAYOUT_LIST); + } + + @Override + public void setFolderLayout(@Nullable OCFile folder, String layoutName) { + setFolderPreference(context, + userAccountManager.getUser(), + PREF__FOLDER_LAYOUT, + folder, + layoutName); + } + + @Override + public FileSortOrder getSortOrderByFolder(OCFile folder) { + return FileSortOrder.sortOrders.get(getFolderPreference(context, + userAccountManager.getUser(), + PREF__FOLDER_SORT_ORDER, + folder, + FileSortOrder.SORT_A_TO_Z.name)); + } + + @Override + public void setSortOrder(@Nullable OCFile folder, FileSortOrder sortOrder) { + setFolderPreference(context, + userAccountManager.getUser(), + PREF__FOLDER_SORT_ORDER, + folder, + sortOrder.name); + } + + @Override + public FileSortOrder getSortOrderByType(FileSortOrder.Type type) { + return getSortOrderByType(type, FileSortOrder.SORT_A_TO_Z); + } + + @Override + public FileSortOrder getSortOrderByType(FileSortOrder.Type type, FileSortOrder defaultOrder) { + User user = userAccountManager.getUser(); + if (user.isAnonymous()) { + return defaultOrder; + } + + ArbitraryDataProvider dataProvider = new ArbitraryDataProviderImpl(context); + + String value = dataProvider.getValue(user.getAccountName(), PREF__FOLDER_SORT_ORDER + "_" + type); + + return value.isEmpty() ? defaultOrder : FileSortOrder.sortOrders.get(value); + } + + @Override + public void setSortOrder(FileSortOrder.Type type, FileSortOrder sortOrder) { + User user = userAccountManager.getUser(); + ArbitraryDataProvider dataProvider = new ArbitraryDataProviderImpl(context); + dataProvider.storeOrUpdateKeyValue(user.getAccountName(), PREF__FOLDER_SORT_ORDER + "_" + type, sortOrder.name); + } + + @Override + public boolean isLegacyClean() { + return preferences.getBoolean(PREF__LEGACY_CLEAN, false); + } + + @Override + public void setLegacyClean(boolean isLegacyClean) { + preferences.edit().putBoolean(PREF__LEGACY_CLEAN, isLegacyClean).apply(); + } + + @Override + public boolean isKeysMigrationEnabled() { + return preferences.getBoolean(PREF__KEYS_MIGRATION, false); + } + + @Override + public void setKeysMigrationEnabled(boolean keysMigration) { + preferences.edit().putBoolean(PREF__KEYS_MIGRATION, keysMigration).apply(); + } + + @Override + public boolean isStoragePathFixEnabled() { + return preferences.getBoolean(PREF__FIX_STORAGE_PATH, false); + } + + @Override + public void setStoragePathFixEnabled(boolean storagePathFixEnabled) { + preferences.edit().putBoolean(PREF__FIX_STORAGE_PATH, storagePathFixEnabled).apply(); + } + + @Override + public boolean isAutoUploadPathsUpdateEnabled() { + return preferences.getBoolean(PREF__AUTO_UPLOAD_UPDATE_PATH, false); + } + + @Override + public void setAutoUploadPathsUpdateEnabled(boolean pathUpdate) { + preferences.edit().putBoolean(PREF__AUTO_UPLOAD_UPDATE_PATH, pathUpdate).apply(); + } + + @Override + public boolean isAutoUploadSplitEntriesEnabled() { + return preferences.getBoolean(PREF__AUTO_UPLOAD_SPLIT_OUT, false); + } + + @Override + public void setAutoUploadSplitEntriesEnabled(boolean splitOut) { + preferences.edit().putBoolean(PREF__AUTO_UPLOAD_SPLIT_OUT, splitOut).apply(); + } + + @Override + public boolean isAutoUploadInitialized() { + return preferences.getBoolean(PREF__AUTO_UPLOAD_INIT, false); + } + + @Override + public void setAutoUploadInit(boolean autoUploadInit) { + preferences.edit().putBoolean(PREF__AUTO_UPLOAD_INIT, autoUploadInit).apply(); + } + + @Override + public int getUploaderBehaviour() { + return preferences.getInt(AUTO_PREF__UPLOADER_BEHAVIOR, 1); + } + + @Override + public boolean isDarkModeEnabled() { + DarkMode mode = getDarkThemeMode(); + + if (mode == DarkMode.SYSTEM) { + int currentNightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return currentNightMode == Configuration.UI_MODE_NIGHT_YES; + } + + return mode == DarkMode.DARK; + } + + @Override + public void setDarkThemeMode(DarkMode mode) { + preferences.edit().putString(PREF__DARK_THEME, mode.name()).apply(); + } + + @Override + public DarkMode getDarkThemeMode() { + try { + return DarkMode.valueOf(preferences.getString(PREF__DARK_THEME, DarkMode.SYSTEM.name())); + } catch (ClassCastException e) { + preferences.edit().putString(PREF__DARK_THEME, DarkMode.SYSTEM.name()).apply(); + return DarkMode.SYSTEM; + } + } + + @Override + public void setUploaderBehaviour(int uploaderBehaviour) { + preferences.edit().putInt(AUTO_PREF__UPLOADER_BEHAVIOR, uploaderBehaviour).apply(); + } + + /** + * Gets the grid columns which the user has set last. + * + * @return grid columns grid columns + */ + @Override + public float getGridColumns() { + float columns = preferences.getFloat(AUTO_PREF__GRID_COLUMNS, DEFAULT_GRID_COLUMN); + + if (columns < 0) { + return DEFAULT_GRID_COLUMN; + } else { + return columns; + } + } + + /** + * Saves the grid columns which the user has set last. + * + * @param gridColumns the uploader behavior + */ + @Override + public void setGridColumns(float gridColumns) { + preferences.edit().putFloat(AUTO_PREF__GRID_COLUMNS, gridColumns).apply(); + } + + @Override + public int getLastSeenVersionCode() { + return preferences.getInt(AUTO_PREF__LAST_SEEN_VERSION_CODE, 0); + } + + @Override + public void saveLogEntry(List logEntryList) { + Gson gson = new Gson(); + String json = gson.toJson(logEntryList); + preferences.edit().putString(LOG_ENTRY, json).apply(); + } + + @Override + public List readLogEntry() { + String json = preferences.getString(LOG_ENTRY, null); + if (json == null) return emptyList(); + Gson gson = new Gson(); + Type listType = new TypeToken>() {}.getType(); + return gson.fromJson(json, listType); + } + + @Override + public void setLastSeenVersionCode(int versionCode) { + preferences.edit().putInt(AUTO_PREF__LAST_SEEN_VERSION_CODE, versionCode).apply(); + } + + @Override + public long getLockTimestamp() { + return preferences.getLong(PREF__LOCK_TIMESTAMP, 0); + } + + @Override + public void setLockTimestamp(long timestamp) { + preferences.edit().putLong(PREF__LOCK_TIMESTAMP, timestamp).apply(); + } + + @Override + public boolean isShowDetailedTimestampEnabled() { + return preferences.getBoolean(AUTO_PREF__SHOW_DETAILED_TIMESTAMP, false); + } + + @Override + public void setShowDetailedTimestampEnabled(boolean showDetailedTimestamp) { + preferences.edit().putBoolean(AUTO_PREF__SHOW_DETAILED_TIMESTAMP, showDetailedTimestamp).apply(); + } + + @Override + public boolean isShowMediaScanNotifications() { + return preferences.getBoolean(PREF__SHOW_MEDIA_SCAN_NOTIFICATIONS, true); + } + + @Override + public void setShowMediaScanNotifications(boolean value) { + preferences.edit().putBoolean(PREF__SHOW_MEDIA_SCAN_NOTIFICATIONS, value).apply(); + } + + @Override + public void removeLegacyPreferences() { + preferences.edit() + .remove("instant_uploading") + .remove("instant_video_uploading") + .remove("instant_upload_path") + .remove("instant_upload_path_use_subfolders") + .remove("instant_upload_on_wifi") + .remove("instant_upload_on_charging") + .remove("instant_video_upload_path") + .remove("instant_video_upload_path_use_subfolders") + .remove("instant_video_upload_on_wifi") + .remove("instant_video_uploading") + .remove("instant_video_upload_on_charging") + .remove("prefs_instant_behaviour") + .apply(); + } + + @Override + public void clear() { + preferences.edit().clear().apply(); + } + + @Override + public String getStoragePath(String defaultPath) { + return preferences.getString(STORAGE_PATH, defaultPath); + } + + @SuppressLint("ApplySharedPref") + @Override + public void setStoragePath(String path) { + preferences.edit().putString(STORAGE_PATH, path).commit(); // commit synchronously + } + + @SuppressLint("ApplySharedPref") + @Override + public void setStoragePathValid() { + preferences.edit().putBoolean(STORAGE_PATH_VALID, true).commit(); + } + + @Override + public boolean isStoragePathValid() { + return preferences.getBoolean(STORAGE_PATH_VALID, false); + } + + /** + * Removes keys migration key from shared preferences. + */ + @SuppressLint("ApplySharedPref") + @Override + public void removeKeysMigrationPreference() { + preferences.edit().remove(AppPreferencesImpl.PREF__KEYS_MIGRATION).commit(); // commit synchronously + } + + @Override + public String getCurrentAccountName() { + return preferences.getString(PREF__SELECTED_ACCOUNT_NAME, null); + } + + @Override + public void setCurrentAccountName(String accountName) { + preferences.edit().putString(PREF__SELECTED_ACCOUNT_NAME, accountName).apply(); + } + + @Override + public boolean isUserIdMigrated() { + return preferences.getBoolean(PREF__MIGRATED_USER_ID, false); + } + + @Override + public void setMigratedUserId(boolean value) { + preferences.edit().putBoolean(PREF__MIGRATED_USER_ID, value).apply(); + } + + @Override + public void setPhotoSearchTimestamp(long timestamp) { + preferences.edit().putLong(PREF__PHOTO_SEARCH_TIMESTAMP, timestamp).apply(); + } + + @Override + public long getPhotoSearchTimestamp() { + return preferences.getLong(PREF__PHOTO_SEARCH_TIMESTAMP, 0); + } + + /** + * Retrieves a preference value for a specific folder. + *

+ * If the folder itself does not have the preference set, the method searches up its ancestor hierarchy + * until a value is found. If no value is found in any ancestor, the provided {@code defaultValue} is returned. + *

+ * Anonymous users or {@code null} folders will always return the {@code defaultValue}. + * + * @param context The Android context. + * @param user The user for whom the preference is queried. + * @param preferenceName The name/key of the preference to look up. + * @param folder The folder to check. + * @param defaultValue The value to return if no preference is set in the folder hierarchy. + * @return The preference value for the folder, or {@code defaultValue} if none is set. + */ + private static String getFolderPreference(final Context context, + final User user, + final String preferenceName, + final OCFile folder, + final String defaultValue) { + if (user.isAnonymous() || folder == null) { + return defaultValue; + } + + ArbitraryDataProvider dataProvider = new ArbitraryDataProviderImpl(context); + FileDataStorageManager storageManager = new FileDataStorageManager(user, context.getContentResolver()); + + String value = dataProvider.getValue(user.getAccountName(), getKeyFromFolder(preferenceName, folder)); + OCFile prefFolder = folder; + while (prefFolder != null && value.isEmpty()) { + prefFolder = storageManager.getFileById(prefFolder.getParentId()); + value = dataProvider.getValue(user.getAccountName(), getKeyFromFolder(preferenceName, prefFolder)); + } + return value.isEmpty() ? defaultValue : value; + } + + /** + * Set preference value for a folder. + * + * @param context Context object. + * @param preferenceName Name of the preference to set. + * @param folder Folder. + * @param value Preference value to set. + */ + private static void setFolderPreference(final Context context, + final User user, + final String preferenceName, + @Nullable final OCFile folder, + final String value) { + ArbitraryDataProvider dataProvider = new ArbitraryDataProviderImpl(context); + dataProvider.storeOrUpdateKeyValue(user.getAccountName(), getKeyFromFolder(preferenceName, folder), value); + } + + private static String getKeyFromFolder(String preferenceName, @Nullable OCFile folder) { + final String folderIdString = String.valueOf(folder != null ? folder.getFileId() : + FileDataStorageManager.ROOT_PARENT_ID); + + return preferenceName + "_" + folderIdString; + } + + public void increasePinWrongAttempts() { + int count = preferences.getInt(PREF__PIN_BRUTE_FORCE_COUNT, 0); + preferences.edit().putInt(PREF__PIN_BRUTE_FORCE_COUNT, count + 1).apply(); + } + + @Override + public void resetPinWrongAttempts() { + preferences.edit().putInt(PREF__PIN_BRUTE_FORCE_COUNT, 0).apply(); + } + + public int pinBruteForceDelay() { + int count = preferences.getInt(PREF__PIN_BRUTE_FORCE_COUNT, 0); + + return computeBruteForceDelay(count); + } + + @Override + public String getUidPid() { + return preferences.getString(PREF__UID_PID, ""); + } + + @Override + public void setUidPid(String uidPid) { + preferences.edit().putString(PREF__UID_PID, uidPid).apply(); + } + + @Override + public long getCalendarLastBackup() { + return preferences.getLong(PREF__CALENDAR_LAST_BACKUP, 0); + } + + @Override + public void setCalendarLastBackup(long timestamp) { + preferences.edit().putLong(PREF__CALENDAR_LAST_BACKUP, timestamp).apply(); + } + + @Override + public boolean isGlobalUploadPaused() { + return preferences.getBoolean(PREF__GLOBAL_PAUSE_STATE,false); + } + + @Override + public void setGlobalUploadPaused(boolean globalPausedState) { + preferences.edit().putBoolean(PREF__GLOBAL_PAUSE_STATE, globalPausedState).apply(); + } + + @Override + public boolean isStoragePermissionRequested() { + return preferences.getBoolean(PREF__STORAGE_PERMISSION_REQUESTED, false); + } + + @Override + public void setStoragePermissionRequested(boolean value) { + preferences.edit().putBoolean(PREF__STORAGE_PERMISSION_REQUESTED, value).apply(); + } + + @VisibleForTesting + public int computeBruteForceDelay(int count) { + return (int) Math.min(count / 3d, 10); + } + @Override + public void setInAppReviewData(@NonNull AppReviewShownModel appReviewShownModel) { + Gson gson = new Gson(); + String json = gson.toJson(appReviewShownModel); + preferences.edit().putString(PREF__IN_APP_REVIEW_DATA, json).apply(); + } + + @Nullable + @Override + public AppReviewShownModel getInAppReviewData() { + Gson gson = new Gson(); + String json = preferences.getString(PREF__IN_APP_REVIEW_DATA, ""); + return gson.fromJson(json, AppReviewShownModel.class); + } + + @Override + public void setLastSelectedMediaFolder(@NonNull String path) { + preferences.edit().putString(PREF__MEDIA_FOLDER_LAST_PATH, path).apply(); + } + + @NonNull + @Override + public String getLastSelectedMediaFolder() { + return preferences.getString(PREF__MEDIA_FOLDER_LAST_PATH, OCFile.ROOT_PATH); + } + + @Override + public void setTwoWaySyncStatus(boolean value) { + preferences.edit().putBoolean(PREF__TWO_WAY_STATUS, value).apply(); + } + + @Override + public boolean isTwoWaySyncEnabled() { + return preferences.getBoolean(PREF__TWO_WAY_STATUS, true); + } + + @Override + public void setTwoWaySyncInterval(Long value) { + preferences.edit().putLong(PREF__TWO_WAY_SYNC_INTERVAL, value).apply(); + } + + @Override + public Long getTwoWaySyncInterval() { + return preferences.getLong(PREF__TWO_WAY_SYNC_INTERVAL, 15L); + } + + @Override + public boolean shouldStopDownloadJobsOnStart() { + return preferences.getBoolean(PREF__STOP_DOWNLOAD_JOBS_ON_START, true); + } + + @Override + public void setStopDownloadJobsOnStart(boolean value) { + preferences.edit().putBoolean(PREF__STOP_DOWNLOAD_JOBS_ON_START, value).apply(); + } + + @Override + public int getPassCodeDelay() { + return preferences.getInt(PREF__PASSCODE_DELAY_IN_SECONDS, 0); + } + + @Override + public void setPassCodeDelay(int value) { + preferences.edit().putInt(PREF__PASSCODE_DELAY_IN_SECONDS, value).apply(); + } + + @Override + public String getLastDisplayedAccountName() { + return preferences.getString(PREF_LAST_DISPLAYED_ACCOUNT_NAME, null); + } + + @Override + public void setLastDisplayedAccountName(String lastDisplayedAccountName) { + preferences.edit().putString(PREF_LAST_DISPLAYED_ACCOUNT_NAME, lastDisplayedAccountName).apply(); + } + + @Override + public boolean startAutoUploadOnStart() { + long lastRunTime = preferences.getLong(AUTO_PREF__LAST_AUTO_UPLOAD_ON_START, 0L); + long now = System.currentTimeMillis(); + return lastRunTime == 0L || (now - lastRunTime) >= AUTO_UPLOAD_ON_START_DEBOUNCE_MS; + } + + @Override + public void setLastAutoUploadOnStartTime(long timeInMillisecond) { + preferences.edit().putLong(AUTO_PREF__LAST_AUTO_UPLOAD_ON_START, timeInMillisecond).apply(); + } +} diff --git a/app/src/main/java/com/nextcloud/client/preferences/DarkMode.java b/app/src/main/java/com/nextcloud/client/preferences/DarkMode.java new file mode 100644 index 000000000000..c516893acd0c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/preferences/DarkMode.java @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.preferences; + +public enum DarkMode { + DARK, LIGHT, SYSTEM +} diff --git a/app/src/main/java/com/nextcloud/client/preferences/PreferencesModule.java b/app/src/main/java/com/nextcloud/client/preferences/PreferencesModule.java new file mode 100644 index 000000000000..09f4a8a28e9a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/preferences/PreferencesModule.java @@ -0,0 +1,35 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.preferences; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.nextcloud.client.account.UserAccountManager; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class PreferencesModule { + + @Provides + @Singleton + public SharedPreferences sharedPreferences(Context context) { + return android.preference.PreferenceManager.getDefaultSharedPreferences(context); + } + + @Provides + @Singleton + public AppPreferences appPreferences(Context context, + SharedPreferences sharedPreferences, + UserAccountManager userAccountManager) { + return new AppPreferencesImpl(context, sharedPreferences, userAccountManager); + } +} diff --git a/app/src/main/java/com/nextcloud/client/preferences/SubFolderRule.kt b/app/src/main/java/com/nextcloud/client/preferences/SubFolderRule.kt new file mode 100644 index 000000000000..85412031e749 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/preferences/SubFolderRule.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Android client application + * + * @author Dean Birch + * Copyright (C) 2023 Dean Birch + * Copyright (C) 2023 Nextcloud GmbH + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.preferences + +enum class SubFolderRule { + YEAR_MONTH, + YEAR, + YEAR_MONTH_DAY +} diff --git a/app/src/main/java/com/nextcloud/client/utils/HashUtil.kt b/app/src/main/java/com/nextcloud/client/utils/HashUtil.kt new file mode 100644 index 000000000000..625237f69d00 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/utils/HashUtil.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: MIT + */ +package com.nextcloud.client.utils + +import org.apache.commons.codec.binary.Hex +import java.security.MessageDigest + +object HashUtil { + private const val ALGORITHM_MD5 = "MD5" + + @JvmStatic + fun md5Hash(input: String): String { + val digest = MessageDigest.getInstance(ALGORITHM_MD5) + .digest(input.toByteArray()) + return String(Hex.encodeHex(digest)) + } +} diff --git a/app/src/main/java/com/nextcloud/client/utils/IntentUtil.kt b/app/src/main/java/com/nextcloud/client/utils/IntentUtil.kt new file mode 100644 index 000000000000..1f3dfa2f21fd --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/utils/IntentUtil.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.owncloud.android.datamodel.OCFile + +object IntentUtil { + + @JvmStatic + public fun createSendIntent(context: Context, file: OCFile): Intent = createBaseSendFileIntent().apply { + action = Intent.ACTION_SEND + type = file.mimeType + putExtra(Intent.EXTRA_STREAM, file.getExposedFileUri(context)) + } + + @JvmStatic + public fun createSendIntent(context: Context, files: Array): Intent = createBaseSendFileIntent().apply { + action = Intent.ACTION_SEND_MULTIPLE + type = getUniqueMimetype(files) + putParcelableArrayListExtra(Intent.EXTRA_STREAM, getExposedFileUris(context, files)) + } + + private fun createBaseSendFileIntent(): Intent = Intent().apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + private fun getUniqueMimetype(files: Array): String? = when { + files.distinctBy { it.mimeType }.size > 1 -> "*/*" + else -> files[0].mimeType + } + + private fun getExposedFileUris(context: Context, files: Array): ArrayList = + ArrayList(files.map { it.getExposedFileUri(context) }) +} diff --git a/app/src/main/java/com/nextcloud/client/utils/Throttler.kt b/app/src/main/java/com/nextcloud/client/utils/Throttler.kt new file mode 100644 index 000000000000..3e5477287a44 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/utils/Throttler.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Álvaro Brey + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.utils + +import com.nextcloud.client.core.Clock + +/** + * Simple throttler that just discards new calls until interval has passed. + * + * @param clock the Clock to provide timestamps + */ +class Throttler(private val clock: Clock) { + + /** + * The interval, in milliseconds, between accepted calls + */ + @Suppress("MagicNumber") + var intervalMillis = 150L + private val timestamps: MutableMap = mutableMapOf() + + @Synchronized + fun run(key: String, runnable: Runnable) { + val time = clock.currentTime + val lastCallTimestamp = timestamps[key] ?: 0 + if (time - lastCallTimestamp > intervalMillis) { + runnable.run() + timestamps[key] = time + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetConfigurationActivity.kt b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetConfigurationActivity.kt new file mode 100644 index 000000000000..89f1621c8646 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetConfigurationActivity.kt @@ -0,0 +1,221 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.nextcloud.android.lib.resources.dashboard.DashBoardButtonType +import com.nextcloud.android.lib.resources.dashboard.DashboardListWidgetsRemoteOperation +import com.nextcloud.android.lib.resources.dashboard.DashboardWidget +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.ClientFactory.CreationException +import com.owncloud.android.R +import com.owncloud.android.databinding.DashboardWidgetConfigurationLayoutBinding +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.adapter.DashboardWidgetListAdapter +import com.owncloud.android.ui.dialog.AccountChooserInterface +import com.owncloud.android.ui.dialog.MultipleAccountsDialog +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class DashboardWidgetConfigurationActivity : + AppCompatActivity(), + DashboardWidgetConfigurationInterface, + Injectable, + AccountChooserInterface { + private lateinit var mAdapter: DashboardWidgetListAdapter + private lateinit var binding: DashboardWidgetConfigurationLayoutBinding + private lateinit var currentUser: User + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var accountManager: UserAccountManager + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var widgetRepository: WidgetRepository + + @Inject + lateinit var widgetUpdater: DashboardWidgetUpdater + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + public override fun onCreate(bundle: Bundle?) { + super.onCreate(bundle) + + // Set the result to CANCELED. This will cause the widget host to cancel + // out of the widget placement if the user presses the back button. + setResult(RESULT_CANCELED) + + binding = DashboardWidgetConfigurationLayoutBinding.inflate(layoutInflater) + setContentView(binding.root) + + viewThemeUtils.platform.colorDrawable(binding.icon.drawable, getColor(R.color.dark)) + + val layoutManager = LinearLayoutManager(this) + // TODO follow our new architecture + mAdapter = DashboardWidgetListAdapter( + lifecycleScope, + accountManager, + clientFactory, + this, + this + ) + binding.list.apply { + setHasFooter(false) + setAdapter(mAdapter) + setLayoutManager(layoutManager) + setEmptyView(binding.emptyView.emptyListView) + } + + currentUser = accountManager.user + if (accountManager.allUsers.size > 1) { + binding.chooseWidget.visibility = View.GONE + + binding.accountName.apply { + setCompoundDrawablesWithIntrinsicBounds( + null, + null, + viewThemeUtils.platform.colorDrawable( + AppCompatResources.getDrawable( + context, + R.drawable.ic_baseline_arrow_drop_down_24 + )!!, + getColor(R.color.black) + ), + null + ) + visibility = View.VISIBLE + text = currentUser.accountName + setOnClickListener { + val dialog = MultipleAccountsDialog() + dialog.highlightCurrentlyActiveAccount = false + dialog.show(supportFragmentManager, null) + } + } + } + loadWidgets(currentUser) + + binding.close.setOnClickListener { finish() } + + // Find the widget id from the intent. + appWidgetId = intent?.extras?.getInt( + EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + // If this activity was started with an intent without an app widget ID, finish with an error. + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + } + + private fun loadWidgets(user: User) { + binding.emptyView.root.visibility = View.GONE + if (accountManager.allUsers.size > 1) { + binding.accountName.text = user.accountName + } + + try { + CoroutineScope(Dispatchers.IO).launch { + val client = clientFactory.createNextcloudClient(user) + val result = DashboardListWidgetsRemoteOperation().execute(client) + + withContext(Dispatchers.Main) { + if (result.isSuccess) { + mAdapter.setWidgetList(result.resultData) + } else if (result.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND) { + mAdapter.setWidgetList(null) + binding.emptyView.root.visibility = View.VISIBLE + binding.emptyView.emptyListViewHeadline.setText(R.string.widgets_not_available_title) + + binding.emptyView.emptyListIcon.apply { + setImageResource(R.drawable.ic_list_empty_error) + visibility = View.VISIBLE + } + binding.emptyView.emptyListViewText.apply { + text = String.format( + getString(R.string.widgets_not_available), + getString(R.string.app_name) + ) + visibility = View.VISIBLE + } + } + } + } + } catch (e: CreationException) { + Log_OC.e(this, "Error loading widgets for user $user", e) + + mAdapter.setWidgetList(null) + binding.emptyView.root.visibility = View.VISIBLE + + binding.emptyView.emptyListIcon.apply { + setImageResource(R.drawable.ic_list_empty_error) + visibility = View.VISIBLE + } + binding.emptyView.emptyListViewText.apply { + setText(R.string.common_error) + visibility = View.VISIBLE + } + binding.emptyView.emptyListViewAction.apply { + visibility = View.VISIBLE + setText(R.string.reload) + setOnClickListener { + loadWidgets(user) + } + } + } + } + + override fun onItemClicked(dashboardWidget: DashboardWidget) { + widgetRepository.saveWidget(appWidgetId, dashboardWidget, currentUser) + + // update widget + val appWidgetManager = AppWidgetManager.getInstance(this) + + widgetUpdater.updateAppWidget( + appWidgetManager, + appWidgetId, + dashboardWidget.title, + dashboardWidget.iconUrl, + dashboardWidget.buttons?.find { it.type == DashBoardButtonType.NEW } + ) + + val resultValue = Intent().apply { + putExtra(EXTRA_APPWIDGET_ID, appWidgetId) + } + + setResult(RESULT_OK, resultValue) + finish() + } + + override fun onAccountChosen(user: User) { + currentUser = user + loadWidgets(user) + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetConfigurationInterface.kt b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetConfigurationInterface.kt new file mode 100644 index 000000000000..f62adca55040 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetConfigurationInterface.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget + +import com.nextcloud.android.lib.resources.dashboard.DashboardWidget + +interface DashboardWidgetConfigurationInterface { + fun onItemClicked(dashboardWidget: DashboardWidget) +} diff --git a/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetProvider.kt b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetProvider.kt new file mode 100644 index 000000000000..18fc2074a2c1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetProvider.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import dagger.android.AndroidInjection +import javax.inject.Inject + +/** + * Manages widgets + */ +class DashboardWidgetProvider : AppWidgetProvider() { + @Inject + lateinit var widgetRepository: WidgetRepository + + @Inject + lateinit var widgetUpdater: DashboardWidgetUpdater + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + AndroidInjection.inject(this, context) + + for (appWidgetId in appWidgetIds) { + val widgetConfiguration = widgetRepository.getWidget(appWidgetId) + + widgetUpdater.updateAppWidget( + appWidgetManager, + appWidgetId, + widgetConfiguration.title, + widgetConfiguration.iconUrl, + widgetConfiguration.addButton + ) + } + } + + override fun onReceive(context: Context?, intent: Intent?) { + super.onReceive(context, intent) + AndroidInjection.inject(this, context) + + if (intent?.action == OPEN_INTENT) { + context?.let { + val clickIntent = Intent(Intent.ACTION_VIEW, intent.data).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(clickIntent) + } + } + } + + override fun onDeleted(context: Context?, appWidgetIds: IntArray) { + AndroidInjection.inject(this, context) + + for (appWidgetId in appWidgetIds) { + widgetRepository.deleteWidget(appWidgetId) + } + } + + companion object { + const val OPEN_INTENT = "open" + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetService.kt b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetService.kt new file mode 100644 index 000000000000..a55157d00ec8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetService.kt @@ -0,0 +1,216 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.view.View +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import com.nextcloud.android.lib.resources.dashboard.DashboardGetWidgetItemsRemoteOperation +import com.nextcloud.android.lib.resources.dashboard.DashboardWidgetItem +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.utils.GlideHelper +import com.owncloud.android.R +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.BitmapUtils +import dagger.android.AndroidInjection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class DashboardWidgetService : RemoteViewsService() { + @Inject + lateinit var userAccountManager: UserAccountManager + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var widgetRepository: WidgetRepository + + override fun onCreate() { + super.onCreate() + AndroidInjection.inject(this) + } + + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = StackRemoteViewsFactory( + this.applicationContext, + userAccountManager, + clientFactory, + intent, + widgetRepository + ) +} + +class StackRemoteViewsFactory( + private val context: Context, + val userAccountManager: UserAccountManager, + val clientFactory: ClientFactory, + val intent: Intent, + private val widgetRepository: WidgetRepository +) : RemoteViewsService.RemoteViewsFactory { + + private lateinit var widgetConfiguration: WidgetConfiguration + private var widgetItems: List = emptyList() + private var hasLoadMore = false + + override fun onCreate() { + Log_OC.d(TAG, "onCreate") + val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) + + widgetConfiguration = widgetRepository.getWidget(appWidgetId) + + if (!widgetConfiguration.user.isPresent) { + // TODO show error + Log_OC.e(this, "No user found!") + } + + onDataSetChanged() + } + + override fun onDataSetChanged() { + CoroutineScope(Dispatchers.IO).launch { + try { + if (!widgetConfiguration.user.isPresent) { + Log_OC.w(TAG, "User not present for widget update") + return@launch + } + + val client = clientFactory.createNextcloudClient(widgetConfiguration.user.get()) + val result = DashboardGetWidgetItemsRemoteOperation(widgetConfiguration.widgetId, LIMIT_SIZE) + .execute(client) + widgetItems = if (result.isSuccess) { + result.resultData[widgetConfiguration.widgetId] ?: emptyList() + } else { + emptyList() + } + hasLoadMore = widgetConfiguration.moreButton != null && widgetItems.size == LIMIT_SIZE + } catch (e: ClientFactory.CreationException) { + Log_OC.e(TAG, "Error updating widget", e) + } + } + + Log_OC.d(TAG, "onDataSetChanged") + } + + override fun onDestroy() { + Log_OC.d(TAG, "onDestroy") + + widgetItems = emptyList() + } + + override fun getCount(): Int = if (hasLoadMore && widgetItems.isNotEmpty()) { + widgetItems.size + 1 + } else { + widgetItems.size + } + + override fun getViewAt(position: Int): RemoteViews = if (position == widgetItems.size) { + createLoadMoreView() + } else { + createItemView(position) + } + + private fun createLoadMoreView(): RemoteViews = + RemoteViews(context.packageName, R.layout.widget_item_load_more).apply { + val clickIntent = Intent(Intent.ACTION_VIEW, widgetConfiguration.moreButton?.link?.toUri()) + setTextViewText(R.id.load_more, widgetConfiguration.moreButton?.text) + setOnClickFillInIntent(R.id.load_more_container, clickIntent) + } + + // we will switch soon to coil and then streamline all of this + // Kotlin cannot catch multiple exception types at same time + @Suppress("NestedBlockDepth") + private fun createItemView(position: Int): RemoteViews { + return RemoteViews(context.packageName, R.layout.widget_item).apply { + if (widgetItems.isEmpty()) { + return@apply + } + + val widgetItem = widgetItems[position] + + if (widgetItem.iconUrl.isNotEmpty()) { + loadIcon(widgetItem, this) + } + + updateTexts(widgetItem, this) + + if (widgetItem.link.isNotEmpty()) { + val clickIntent = Intent(Intent.ACTION_VIEW, widgetItem.link.toUri()) + setOnClickFillInIntent(R.id.text_container, clickIntent) + } + } + } + + private fun loadIcon(widgetItem: DashboardWidgetItem, remoteViews: RemoteViews) { + CoroutineScope(Dispatchers.IO).launch { + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(userAccountManager.user.toOwnCloudAccount(), context) + val pictureDrawable = GlideHelper.getDrawable(context, client, widgetItem.iconUrl) + val bitmap = pictureDrawable?.toBitmap() ?: return@launch + + withContext(Dispatchers.Main) { + remoteViews.setRemoteImageView(bitmap) + return@withContext + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun RemoteViews.setRemoteImageView(source: Bitmap) { + try { + val bitmap: Bitmap = if (widgetConfiguration.roundIcon) { + BitmapUtils.roundBitmap(source) + } else { + source + } + + setImageViewBitmap(R.id.icon, bitmap) + } catch (e: Exception) { + Log_OC.d(TAG, "Error setting icon", e) + setImageViewResource(R.id.icon, R.drawable.ic_dashboard) + } + } + + private fun updateTexts(widgetItem: DashboardWidgetItem, remoteViews: RemoteViews) { + remoteViews.setTextViewText(R.id.title, widgetItem.title) + + if (widgetItem.subtitle.isNotEmpty()) { + remoteViews.setViewVisibility(R.id.subtitle, View.VISIBLE) + remoteViews.setTextViewText(R.id.subtitle, widgetItem.subtitle) + } else { + remoteViews.setViewVisibility(R.id.subtitle, View.GONE) + } + } + + override fun getLoadingView(): RemoteViews? = null + + override fun getViewTypeCount(): Int = if (hasLoadMore) { + 2 + } else { + 1 + } + + override fun getItemId(position: Int): Long = position.toLong() + + override fun hasStableIds(): Boolean = true + + companion object { + private val TAG = DashboardWidgetService::class.simpleName + const val LIMIT_SIZE = 14 + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetUpdater.kt b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetUpdater.kt new file mode 100644 index 000000000000..abbff4c4c95d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/DashboardWidgetUpdater.kt @@ -0,0 +1,169 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.view.View +import android.widget.RemoteViews +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import com.bumptech.glide.request.target.AppWidgetTarget +import com.nextcloud.android.lib.resources.dashboard.DashboardButton +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.utils.GlideHelper +import com.owncloud.android.R +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.utils.BitmapUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class DashboardWidgetUpdater @Inject constructor( + private val context: Context, + private val accountProvider: CurrentAccountProvider +) { + private val scope = CoroutineScope(Dispatchers.IO) + + fun updateAppWidget( + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + title: String, + iconUrl: String, + addButton: DashboardButton? + ) { + val intent = Intent(context, DashboardWidgetService::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + data = toUri(Intent.URI_INTENT_SCHEME).toUri() + } + + val views = RemoteViews(context.packageName, R.layout.dashboard_widget).apply { + setRemoteAdapter(R.id.list, intent) + setEmptyView(R.id.list, R.id.empty_view) + setTextViewText(R.id.title, title) + + setAddButton(addButton, appWidgetId, this) + setPendingReload(this, appWidgetId) + setPendingClick(this) + + if (iconUrl.isNotEmpty()) { + loadIcon(appWidgetId, iconUrl, this) + } + } + + appWidgetManager.run { + updateAppWidget(appWidgetId, views) + notifyAppWidgetViewDataChanged(appWidgetId, R.id.list) + } + } + + private fun setPendingReload(remoteViews: RemoteViews, appWidgetId: Int) { + val pendingIntent = getReloadPendingIntent(appWidgetId) + remoteViews.setOnClickPendingIntent(R.id.reload, pendingIntent) + } + + private fun setPendingClick(remoteViews: RemoteViews) { + val intent = Intent().apply { + setPackage(context.packageName) + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + pendingIntentFlags + ) + + remoteViews.setPendingIntentTemplate(R.id.list, pendingIntent) + } + + private fun setAddButton(addButton: DashboardButton?, appWidgetId: Int, remoteViews: RemoteViews) { + remoteViews.run { + if (addButton == null) { + setViewVisibility(R.id.create, View.GONE) + } else { + setViewVisibility(R.id.create, View.VISIBLE) + setContentDescription(R.id.create, addButton.text) + + val pendingIntent = getAddPendingIntent(appWidgetId, addButton) + + setOnClickPendingIntent( + R.id.create, + pendingIntent + ) + } + } + } + + // region PendingIntents + private fun getReloadPendingIntent(appWidgetId: Int): PendingIntent { + val intent = Intent(context, DashboardWidgetProvider::class.java).apply { + setPackage(context.packageName) + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + + val idArray = intArrayOf(appWidgetId) + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, idArray) + } + + return PendingIntent.getBroadcast( + context, + appWidgetId, + intent, + pendingIntentFlags + ) + } + + private fun getAddPendingIntent(appWidgetId: Int, addButton: DashboardButton): PendingIntent { + val intent = Intent(context, DashboardWidgetProvider::class.java).apply { + setPackage(context.packageName) + action = DashboardWidgetProvider.OPEN_INTENT + data = addButton.link.toUri() + } + + return PendingIntent.getBroadcast( + context, + appWidgetId, + intent, + pendingIntentFlags + ) + } + + @Suppress("MagicNumber") + private val pendingIntentFlags: Int = when { + Build.VERSION.SDK_INT >= 34 -> { + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_MUTABLE or + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT + } + + else -> { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } + } + // endregion + + private fun loadIcon(appWidgetId: Int, iconUrl: String, remoteViews: RemoteViews) { + val target = AppWidgetTarget(context, R.id.icon, remoteViews, appWidgetId) + scope.launch { + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(accountProvider.user.toOwnCloudAccount(), context) + val drawable = GlideHelper.getDrawable(context, client, iconUrl) + val bitmap = drawable?.toBitmap() ?: return@launch + val tintedBitmap = BitmapUtils.tintImage(bitmap, R.color.black) + + withContext(Dispatchers.Main) { + target.onResourceReady(tintedBitmap, null) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/WidgetConfiguration.kt b/app/src/main/java/com/nextcloud/client/widget/WidgetConfiguration.kt new file mode 100644 index 000000000000..89b5ea35a114 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/WidgetConfiguration.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget + +import com.nextcloud.android.lib.resources.dashboard.DashboardButton +import com.nextcloud.client.account.User +import java.util.Optional + +data class WidgetConfiguration( + val widgetId: String, + val title: String, + val iconUrl: String, + val roundIcon: Boolean, + val user: Optional, + val addButton: DashboardButton?, + val moreButton: DashboardButton? +) diff --git a/app/src/main/java/com/nextcloud/client/widget/WidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/WidgetRepository.kt new file mode 100644 index 000000000000..c72f1fb71834 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/WidgetRepository.kt @@ -0,0 +1,136 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.nextcloud.android.lib.resources.dashboard.DashBoardButtonType +import com.nextcloud.android.lib.resources.dashboard.DashboardButton +import com.nextcloud.android.lib.resources.dashboard.DashboardWidget +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import java.util.Optional +import javax.inject.Inject + +class WidgetRepository @Inject constructor( + private val userAccountManager: UserAccountManager, + val preferences: SharedPreferences +) { + fun saveWidget(widgetId: Int, widget: DashboardWidget, user: User) { + preferences + .edit { + putString(PREF__WIDGET_ID + widgetId, widget.id) + .putString(PREF__WIDGET_TITLE + widgetId, widget.title) + .putString(PREF__WIDGET_ICON + widgetId, widget.iconUrl) + .putBoolean(PREF__WIDGET_ROUND_ICON + widgetId, widget.roundIcons) + .putString(PREF__WIDGET_USER + widgetId, user.accountName) + val buttonList = widget.buttons + if (!buttonList.isNullOrEmpty()) { + for (button in buttonList) { + if (button.type == DashBoardButtonType.NEW) { + this + .putString(PREF__WIDGET_ADD_BUTTON_TYPE + widgetId, button.type.toString()) + .putString(PREF__WIDGET_ADD_BUTTON_URL + widgetId, button.link) + .putString(PREF__WIDGET_ADD_BUTTON_TEXT + widgetId, button.text) + } + if (button.type == DashBoardButtonType.MORE) { + this + .putString(PREF__WIDGET_MORE_BUTTON_TYPE + widgetId, button.type.toString()) + .putString(PREF__WIDGET_MORE_BUTTON_URL + widgetId, button.link) + .putString(PREF__WIDGET_MORE_BUTTON_TEXT + widgetId, button.text) + } + } + } + } + } + + fun deleteWidget(widgetId: Int) { + preferences + .edit { + remove(PREF__WIDGET_ID + widgetId) + .remove(PREF__WIDGET_TITLE + widgetId) + .remove(PREF__WIDGET_ICON + widgetId) + .remove(PREF__WIDGET_ROUND_ICON + widgetId) + .remove(PREF__WIDGET_USER + widgetId) + .remove(PREF__WIDGET_ADD_BUTTON_TEXT + widgetId) + .remove(PREF__WIDGET_ADD_BUTTON_URL + widgetId) + .remove(PREF__WIDGET_ADD_BUTTON_TYPE + widgetId) + .remove(PREF__WIDGET_MORE_BUTTON_TEXT + widgetId) + .remove(PREF__WIDGET_MORE_BUTTON_URL + widgetId) + .remove(PREF__WIDGET_MORE_BUTTON_TYPE + widgetId) + } + } + + fun getWidget(widgetId: Int): WidgetConfiguration { + val userOptional: Optional = + userAccountManager.getUser(preferences.getString(PREF__WIDGET_USER + widgetId, "")) + + val addButton = createAddButton(widgetId) + val moreButton = createMoreButton(widgetId) + + return WidgetConfiguration( + preferences.getString(PREF__WIDGET_ID + widgetId, "") ?: "", + preferences.getString(PREF__WIDGET_TITLE + widgetId, "") ?: "", + preferences.getString(PREF__WIDGET_ICON + widgetId, "") ?: "", + preferences.getBoolean(PREF__WIDGET_ROUND_ICON + widgetId, false), + userOptional, + addButton, + moreButton + ) + } + + private fun createAddButton(widgetId: Int): DashboardButton? { + var addButton: DashboardButton? = null + if (preferences.contains(PREF__WIDGET_ADD_BUTTON_TYPE + widgetId)) { + addButton = DashboardButton( + DashBoardButtonType.valueOf( + preferences.getString( + PREF__WIDGET_ADD_BUTTON_TYPE + widgetId, + "" + ) ?: "" + ), + preferences.getString(PREF__WIDGET_ADD_BUTTON_TEXT + widgetId, "") ?: "", + preferences.getString(PREF__WIDGET_ADD_BUTTON_URL + widgetId, "") ?: "" + ) + } + + return addButton + } + + private fun createMoreButton(widgetId: Int): DashboardButton? { + var moreButton: DashboardButton? = null + if (preferences.contains(PREF__WIDGET_MORE_BUTTON_TYPE + widgetId)) { + moreButton = DashboardButton( + DashBoardButtonType.valueOf( + preferences.getString( + PREF__WIDGET_MORE_BUTTON_TYPE + widgetId, + "" + ) ?: "" + ), + preferences.getString(PREF__WIDGET_MORE_BUTTON_TEXT + widgetId, "") ?: "", + preferences.getString(PREF__WIDGET_MORE_BUTTON_URL + widgetId, "") ?: "" + ) + } + + return moreButton + } + + companion object { + const val PREF__WIDGET_TITLE = "widget_title_" + private const val PREF__WIDGET_ID = "widget_id_" + private const val PREF__WIDGET_ICON = "widget_icon_" + private const val PREF__WIDGET_ROUND_ICON = "widget_round_icon_" + private const val PREF__WIDGET_USER = "widget_user_" + private const val PREF__WIDGET_ADD_BUTTON_TEXT = "widget_add_button_text_" + private const val PREF__WIDGET_ADD_BUTTON_URL = "widget_add_button_url_" + private const val PREF__WIDGET_ADD_BUTTON_TYPE = "widget_add_button_type_" + private const val PREF__WIDGET_MORE_BUTTON_TEXT = "widget_more_button_text_" + private const val PREF__WIDGET_MORE_BUTTON_URL = "widget_more_button_url_" + private const val PREF__WIDGET_MORE_BUTTON_TYPE = "widget_more_button_type_" + } +} diff --git a/app/src/main/java/com/nextcloud/model/HTTPStatusCodes.kt b/app/src/main/java/com/nextcloud/model/HTTPStatusCodes.kt new file mode 100644 index 000000000000..05a44d6f2ca2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/model/HTTPStatusCodes.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.model + +@Suppress("MagicNumber") +enum class HTTPStatusCodes(val code: Int) { + SUCCESS(200), + NOT_FOUND(404) +} diff --git a/app/src/main/java/com/nextcloud/model/OfflineOperationType.kt b/app/src/main/java/com/nextcloud/model/OfflineOperationType.kt new file mode 100644 index 000000000000..7f3125878fd9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/model/OfflineOperationType.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.model + +sealed class OfflineOperationType { + abstract val type: String + + data class CreateFolder(override val type: String, var path: String) : OfflineOperationType() + + data class CreateFile( + override val type: String, + val localPath: String, + var remotePath: String, + var mimeType: String + ) : OfflineOperationType() + + data class RenameFile(override val type: String, var ocFileId: Long, val newName: String) : OfflineOperationType() + + data class RemoveFile(override val type: String, var path: String) : OfflineOperationType() +} + +enum class OfflineOperationRawType { + CreateFolder, + CreateFile, + RenameFile, + RemoveFile +} diff --git a/app/src/main/java/com/nextcloud/model/SearchResultEntryType.kt b/app/src/main/java/com/nextcloud/model/SearchResultEntryType.kt new file mode 100644 index 000000000000..7eb6068fb1d0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/model/SearchResultEntryType.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.model + +import com.owncloud.android.R + +enum class SearchResultEntryType { + CalendarEvent, + Folder, + Note, + Contact, + Deck, + Settings, + PDF, + Generic, + SpreadSheet, + Presentation, + Form, + FormTemplate, + Drawing, + Document, + Whiteboard, + TextVCard, + TextCode, + Link, + Font, + Avatar, + Unknown; + + fun iconId(): Int = when (this) { + Avatar -> R.drawable.ic_user + CalendarEvent -> R.drawable.file_calendar + Folder -> R.drawable.folder + Note -> R.drawable.ic_edit + Contact -> R.drawable.file_vcard + Deck -> R.drawable.ic_deck + Settings -> R.drawable.ic_settings + PDF -> R.drawable.file_pdf + Generic -> R.drawable.ic_generic_file_type + SpreadSheet -> R.drawable.file_xls + Presentation -> R.drawable.file_ppt + Form -> R.drawable.ic_form + FormTemplate -> R.drawable.ic_form_template + Drawing -> R.drawable.ic_drawing + Document -> R.drawable.file_doc + Whiteboard -> R.drawable.file_whiteboard + TextVCard -> R.drawable.file_vcard + TextCode -> R.drawable.file_code + Link -> R.drawable.ic_link + Font -> R.drawable.ic_font + Unknown -> R.drawable.ic_find_in_page + } +} diff --git a/app/src/main/java/com/nextcloud/model/ShareeEntry.kt b/app/src/main/java/com/nextcloud/model/ShareeEntry.kt new file mode 100644 index 000000000000..05e9a6ae1946 --- /dev/null +++ b/app/src/main/java/com/nextcloud/model/ShareeEntry.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.model + +import android.content.ContentValues +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.lib.resources.shares.ShareType + +data class ShareeEntry( + val filePath: String?, + val accountOwner: String, + val fileOwnerId: String?, + val shareWithDisplayName: String?, + val shareWithUserId: String?, + val shareType: Int +) { + companion object { + /** + * Extracts a list of share-related ContentValues from a given RemoteFile. + * + * Each RemoteFile can be shared with multiple users (sharees), and this function converts each + * sharee into a ContentValues object, representing a row for insertion into a database. + * + * @param remoteFile The RemoteFile object containing sharee information. + * @param accountName The name of the user account that owns this RemoteFile. + * @return A list of ContentValues representing each share entry, or null if no sharees are found. + */ + fun getContentValues(remoteFile: RemoteFile, accountName: String): List? { + if (remoteFile.sharees.isNullOrEmpty()) { + return null + } + + val result = arrayListOf() + + for (share in remoteFile.sharees) { + val shareType: ShareType? = share?.shareType + if (shareType == null) { + continue + } + + val contentValue = ShareeEntry( + remoteFile.remotePath, + accountName, + remoteFile.ownerId, + share.displayName, + share.userId, + shareType.value + ).toContentValues() + + result.add(contentValue) + } + + return result + } + } + + private fun toContentValues(): ContentValues = ContentValues().apply { + put(ProviderTableMeta.OCSHARES_PATH, filePath) + put(ProviderTableMeta.OCSHARES_ACCOUNT_OWNER, accountOwner) + put(ProviderTableMeta.OCSHARES_USER_ID, fileOwnerId) + put(ProviderTableMeta.OCSHARES_SHARE_WITH_DISPLAY_NAME, shareWithDisplayName) + put(ProviderTableMeta.OCSHARES_SHARE_WITH, shareWithUserId) + put(ProviderTableMeta.OCSHARES_SHARE_TYPE, shareType) + } +} diff --git a/app/src/main/java/com/nextcloud/model/WorkerState.kt b/app/src/main/java/com/nextcloud/model/WorkerState.kt new file mode 100644 index 000000000000..58cbc8fdb5d6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/model/WorkerState.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.model + +sealed class WorkerState { + data object OfflineOperationsCompleted : WorkerState() +} diff --git a/app/src/main/java/com/nextcloud/model/WorkerStateObserver.kt b/app/src/main/java/com/nextcloud/model/WorkerStateObserver.kt new file mode 100644 index 000000000000..f8623f3b440c --- /dev/null +++ b/app/src/main/java/com/nextcloud/model/WorkerStateObserver.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.model + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +object WorkerStateObserver { + private const val BUFFER_CAPACITY = 25 + + private val _events = MutableSharedFlow(extraBufferCapacity = BUFFER_CAPACITY) + val events = _events.asSharedFlow() + + fun send(state: WorkerState) { + _events.tryEmit(state) + } +} diff --git a/app/src/main/java/com/nextcloud/receiver/NetworkChangeReceiver.kt b/app/src/main/java/com/nextcloud/receiver/NetworkChangeReceiver.kt new file mode 100644 index 000000000000..d9d7cbea18ef --- /dev/null +++ b/app/src/main/java/com/nextcloud/receiver/NetworkChangeReceiver.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.nextcloud.client.network.ConnectivityService + +interface NetworkChangeListener { + fun networkAndServerConnectionListener(isNetworkAndServerAvailable: Boolean) +} + +class NetworkChangeReceiver( + private val listener: NetworkChangeListener, + private val connectivityService: ConnectivityService +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent?) { + connectivityService.isNetworkAndServerAvailable { + listener.networkAndServerConnectionListener(it) + } + } +} diff --git a/app/src/main/java/com/nextcloud/repository/ClientRepository.kt b/app/src/main/java/com/nextcloud/repository/ClientRepository.kt new file mode 100644 index 000000000000..6e4233248316 --- /dev/null +++ b/app/src/main/java/com/nextcloud/repository/ClientRepository.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.repository + +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.lib.common.OwnCloudClient + +/** + * Interface defining methods to retrieve Nextcloud and OwnCloudClient clients. + * Provides both callback-based and suspend function versions for flexibility in usage. + */ +interface ClientRepository { + /** + * Retrieves an instance of [NextcloudClient] using a callback. + * + * @param onComplete A callback function that receives the [NextcloudClient] instance once available. + */ + fun getNextcloudClient(onComplete: (NextcloudClient) -> Unit) + + /** + * Retrieves an instance of [NextcloudClient] as a suspend function. + * + * @return The [NextcloudClient] instance, or `null` if it cannot be retrieved. + */ + suspend fun getNextcloudClient(): NextcloudClient? + + /** + * Retrieves an instance of [OwnCloudClient] using a callback. + * + * @param onComplete A callback function that receives the [OwnCloudClient] instance once available. + */ + fun getOwncloudClient(onComplete: (OwnCloudClient) -> Unit) + + /** + * Retrieves an instance of [OwnCloudClient] as a suspend function. + * + * @return The [OwnCloudClient] instance, or `null` if it cannot be retrieved. + */ + suspend fun getOwncloudClient(): OwnCloudClient? +} diff --git a/app/src/main/java/com/nextcloud/repository/RemoteClientRepository.kt b/app/src/main/java/com/nextcloud/repository/RemoteClientRepository.kt new file mode 100644 index 000000000000..5bdfa82dae9a --- /dev/null +++ b/app/src/main/java/com/nextcloud/repository/RemoteClientRepository.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.repository + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.account.User +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Suppress("TooGenericExceptionCaught", "DEPRECATION") +class RemoteClientRepository(private val user: User, private val context: Context, lifecycleOwner: LifecycleOwner) : + ClientRepository { + private val tag = "ClientRepository" + private val clientFactory = OwnCloudClientManagerFactory.getDefaultSingleton() + private val scope = lifecycleOwner.lifecycleScope + + override fun getNextcloudClient(onComplete: (NextcloudClient) -> Unit) { + scope.launch(Dispatchers.IO) { + try { + val client = clientFactory.getNextcloudClientFor(user.toOwnCloudAccount(), context) + onComplete(client) + } catch (e: Exception) { + Log_OC.d(tag, "Exception caught getNextcloudClient(): $e") + } + } + } + + override suspend fun getNextcloudClient(): NextcloudClient? = withContext(Dispatchers.IO) { + try { + clientFactory.getNextcloudClientFor(user.toOwnCloudAccount(), context) + } catch (e: Exception) { + Log_OC.d(tag, "Exception caught getNextcloudClient(): $e") + null + } + } + + override fun getOwncloudClient(onComplete: (OwnCloudClient) -> Unit) { + scope.launch(Dispatchers.IO) { + try { + val client = clientFactory.getClientFor(user.toOwnCloudAccount(), context) + onComplete(client) + } catch (e: Exception) { + Log_OC.d(tag, "Exception caught getOwncloudClient(): $e") + } + } + } + + override suspend fun getOwncloudClient(): OwnCloudClient? = withContext(Dispatchers.IO) { + try { + clientFactory.getClientFor(user.toOwnCloudAccount(), context) + } catch (e: Exception) { + Log_OC.d(tag, "Exception caught getOwncloudClient(): $e") + null + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/ChooseAccountDialogFragment.kt b/app/src/main/java/com/nextcloud/ui/ChooseAccountDialogFragment.kt new file mode 100644 index 000000000000..efd2c7bb4e0a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/ChooseAccountDialogFragment.kt @@ -0,0 +1,267 @@ +/* + * Nextcloud Android client application + * + * @author Infomaniak Network SA + * Copyright (C) 2020 Infomaniak Network SA + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.mdm.MDMConfig +import com.owncloud.android.R +import com.owncloud.android.databinding.DialogChooseAccountBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.ui.StatusDrawable +import com.owncloud.android.ui.activity.BaseActivity +import com.owncloud.android.ui.activity.DrawerActivity +import com.owncloud.android.ui.adapter.UserListAdapter +import com.owncloud.android.ui.adapter.UserListItem +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val ARG_CURRENT_USER_PARAM = "currentUser" + +private const val STATUS_SIZE_IN_DP = 9f + +class ChooseAccountDialogFragment : + DialogFragment(), + AvatarGenerationListener, + UserListAdapter.ClickListener, + Injectable { + private lateinit var dialogView: View + private var currentUser: User? = null + private lateinit var accountManager: UserAccountManager + private var currentStatus: Status? = null + + private var _binding: DialogChooseAccountBinding? = null + val binding get() = _binding!! + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + currentUser = it.getParcelableArgument(ARG_CURRENT_USER_PARAM, User::class.java) + } + } + + @SuppressLint("InflateParams") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = DialogChooseAccountBinding.inflate(layoutInflater) + dialogView = binding.root + + val builder = MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(binding.statusView.context, builder) + + return builder.create() + } + + @Suppress("LongMethod") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + accountManager = (activity as BaseActivity).userAccountManager + currentUser?.let { user -> + + // Defining user picture + binding.currentAccount.userIcon.tag = user.accountName + DisplayUtils.setAvatar( + user, + this, + resources.getDimension(R.dimen.list_item_avatar_icon_radius), + resources, + binding.currentAccount.userIcon, + context + ) + + // Defining user texts, accounts, etc. + binding.currentAccount.userName.text = user.toOwnCloudAccount().displayName + binding.currentAccount.ticker.visibility = View.GONE + binding.currentAccount.account.text = user.accountName + + // Defining user right indicator + val icon = viewThemeUtils.platform.tintPrimaryDrawable(requireContext(), R.drawable.ic_check_circle) + binding.currentAccount.accountMenu.setImageDrawable(icon) + + // Creating adapter for accounts list + val adapter = UserListAdapter( + activity as BaseActivity, + accountManager, + getAccountListItems(), + this, + false, + false, + true, + viewThemeUtils + ) + + if (!MDMConfig.multiAccountSupport(requireContext())) { + binding.addAccount.visibility = View.GONE + } + + binding.accountsList.adapter = adapter + + // Creating listeners for quick-actions + binding.currentAccount.root.setOnClickListener { + dismiss() + } + binding.addAccount.setOnClickListener { + (activity as DrawerActivity).openAddAccount() + } + binding.manageAccounts.setOnClickListener { + (activity as DrawerActivity).openManageAccounts() + } + + binding.onlineStatus.setOnClickListener { + val setStatusDialog = SetOnlineStatusBottomSheet(currentStatus) + setStatusDialog.show((activity as DrawerActivity).supportFragmentManager, "fragment_set_status") + + dismiss() + } + + binding.statusMessage.setOnClickListener { + val setStatusMessageDialog = SetStatusMessageBottomSheet(accountManager.user, currentStatus) + setStatusMessageDialog.show( + (activity as DrawerActivity).supportFragmentManager, + "fragment_set_status_message" + ) + + dismiss() + } + + val capability = FileDataStorageManager(user, context?.contentResolver) + .getCapability(user) + + if (capability.userStatus.isTrue) { + binding.statusView.visibility = View.VISIBLE + } + + loadAndSetUserStatus(user) + } + + themeViews() + } + + private fun loadAndSetUserStatus(user: User) { + viewLifecycleOwner.lifecycleScope.launch { + val status = retrieveUserStatus(user, clientFactory) + + if (isAdded && !isDetached) { + val context = requireContext() + setStatus(status, context) + } + } + } + + private fun themeViews() { + viewThemeUtils.platform.themeDialogDivider(binding.separatorLine) + viewThemeUtils.platform.themeDialog(binding.root) + + viewThemeUtils.material.colorMaterialTextButton(binding.onlineStatus) + viewThemeUtils.dialog.colorDialogMenuText(binding.onlineStatus) + viewThemeUtils.material.colorMaterialTextButton(binding.statusMessage) + viewThemeUtils.dialog.colorDialogMenuText(binding.statusMessage) + viewThemeUtils.material.colorMaterialTextButton(binding.addAccount) + viewThemeUtils.dialog.colorDialogMenuText(binding.addAccount) + viewThemeUtils.material.colorMaterialTextButton(binding.manageAccounts) + viewThemeUtils.dialog.colorDialogMenuText(binding.manageAccounts) + } + + private fun getAccountListItems(): List { + val users = accountManager.allUsers + val adapterUserList: MutableList = ArrayList(users.size) + // Remove the current account from the adapter to display only other accounts + for (user in users) { + if (user != currentUser) { + adapterUserList.add(UserListItem(user)) + } + } + return adapterUserList + } + + /** + * Fragment creator + */ + companion object { + @JvmStatic + fun newInstance(user: User) = ChooseAccountDialogFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_CURRENT_USER_PARAM, user) + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + dialogView + + override fun shouldCallGeneratedCallback(tag: String?, callContext: Any?): Boolean = + (callContext as ImageView).tag.toString() == tag + + override fun avatarGenerated(avatarDrawable: Drawable?, callContext: Any?) { + if (_binding != null) { + binding.currentAccount.userIcon.setImageDrawable(avatarDrawable) + } + } + + override fun onAccountClicked(user: User?) { + (activity as DrawerActivity).accountClicked(user) + } + + override fun onOptionItemClicked(user: User?, view: View?) { + // Un-needed for this context + } + + fun setStatus(newStatus: Status, context: Context) { + currentStatus = newStatus + + val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, context) + binding.currentAccount.ticker.background = null + binding.currentAccount.ticker.setImageDrawable(StatusDrawable(newStatus, size.toFloat(), context)) + binding.currentAccount.ticker.visibility = View.VISIBLE + + binding.currentAccount.status.let { + if (newStatus.message.isNullOrBlank()) { + it.text = getString(R.string.empty) + it.visibility = View.GONE + } else { + it.text = newStatus.message + it.visibility = View.VISIBLE + } + } + + view?.invalidate() + } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } +} diff --git a/app/src/main/java/com/nextcloud/ui/ChooseStorageLocationDialogFragment.kt b/app/src/main/java/com/nextcloud/ui/ChooseStorageLocationDialogFragment.kt new file mode 100644 index 000000000000..6a67415c0168 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/ChooseStorageLocationDialogFragment.kt @@ -0,0 +1,177 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 ZetaTom <70907959+ZetaTom@users.noreply.github.com> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.preference.PreferenceManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.preferences.AppPreferencesImpl +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.DialogDataStorageLocationBinding +import com.owncloud.android.datastorage.DataStorageProvider +import com.owncloud.android.datastorage.StoragePoint +import com.owncloud.android.datastorage.StoragePoint.PrivacyType +import com.owncloud.android.datastorage.StoragePoint.StorageType +import com.owncloud.android.ui.model.ExtendedSettingsActivityDialog +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.io.File +import javax.inject.Inject + +class ChooseStorageLocationDialogFragment : + DialogFragment(), + Injectable { + + private lateinit var binding: DialogDataStorageLocationBinding + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + private val storagePoints = DataStorageProvider.getInstance().availableStoragePoints + + private val selectedStorageType + get() = if (!binding.storageExternalRadio.isChecked) StorageType.INTERNAL else StorageType.EXTERNAL + private val selectedPrivacyType + get() = if (binding.allowMediaIndexSwitch.isChecked) PrivacyType.PUBLIC else PrivacyType.PRIVATE + + override fun onStart() { + super.onStart() + val alertDialog = dialog as AlertDialog + + val positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton + positiveButton?.let { + viewThemeUtils.material.colorMaterialButtonPrimaryTonal(positiveButton) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogDataStorageLocationBinding.inflate(layoutInflater) + + viewThemeUtils.material.colorMaterialSwitch(binding.allowMediaIndexSwitch) + viewThemeUtils.platform.themeRadioButton(binding.storageInternalRadio) + viewThemeUtils.platform.themeRadioButton(binding.storageExternalRadio) + + val builder = MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.storage_choose_location) + .setPositiveButton(R.string.common_ok) { dialog: DialogInterface, _ -> + notifyResult() + dialog.dismiss() + }.setView(binding.root) + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(requireContext(), builder) + + binding.storageRadioGroup.setOnCheckedChangeListener { _, _ -> + updateMediaIndexSwitch() + } + + binding.allowMediaIndexSwitch.setOnCheckedChangeListener { _, _ -> + updateStorageTypeSelection() + } + + return builder.create() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + binding.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupLocationSelection() + super.onViewCreated(view, savedInstanceState) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + activity?.finish() + } + + private fun setupLocationSelection() { + updateStorageTypeSelection() + val currentStorageLocation = getCurrentStorageLocation() ?: return + + val radioButton = when (currentStorageLocation.storageType) { + StorageType.EXTERNAL -> binding.storageExternalRadio + else -> binding.storageInternalRadio + } + + radioButton.isChecked = true + updateMediaIndexSwitch() + } + + private fun getStoragePointLabel(storageType: StorageType, privacyType: PrivacyType): String { + val typeString = when (storageType) { + StorageType.INTERNAL -> getString(R.string.storage_internal_storage) + StorageType.EXTERNAL -> getString(R.string.storage_external_storage) + } + + val storagePath = + storagePoints.find { it.storageType == storageType && it.privacyType == privacyType }?.path + + return storagePath?.let { + val file = File(it) + val totalSpace = file.totalSpace + val usedSpace = totalSpace - file.freeSpace + return String.format( + getString(R.string.file_migration_free_space), + typeString, + DisplayUtils.bytesToHumanReadable(usedSpace), + DisplayUtils.bytesToHumanReadable(totalSpace) + ) + } ?: typeString + } + + private fun updateMediaIndexSwitch() { + val privacyTypes = + storagePoints.filter { it.storageType == selectedStorageType }.map { it.privacyType }.distinct() + binding.allowMediaIndexSwitch.isEnabled = privacyTypes.size > 1 + binding.allowMediaIndexSwitch.isChecked = privacyTypes.contains(PrivacyType.PUBLIC) + } + + private fun updateStorageTypeSelection() { + val hasInternalStorage = storagePoints.any { it.storageType == StorageType.INTERNAL } + val hasExternalStorage = storagePoints.any { it.storageType == StorageType.EXTERNAL } + + binding.storageInternalRadio.isEnabled = hasInternalStorage + binding.storageInternalRadio.text = getStoragePointLabel(StorageType.INTERNAL, selectedPrivacyType) + + binding.storageExternalRadio.isEnabled = hasExternalStorage + binding.storageExternalRadio.text = getStoragePointLabel(StorageType.EXTERNAL, selectedPrivacyType) + } + + private fun getCurrentStorageLocation(): StoragePoint? { + val appContext = MainApp.getAppContext() + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext) + val storagePath = sharedPreferences.getString(AppPreferencesImpl.STORAGE_PATH, appContext.filesDir.absolutePath) + return storagePoints.find { it.path == storagePath } + } + + private fun notifyResult() { + val newPath = + storagePoints.find { it.storageType == selectedStorageType && it.privacyType == selectedPrivacyType } + ?: return + + val resultBundle = Bundle().apply { + putString(ExtendedSettingsActivityDialog.StorageLocation.key, newPath.path) + } + + parentFragmentManager.setFragmentResult(ExtendedSettingsActivityDialog.StorageLocation.key, resultBundle) + } + + companion object { + @JvmStatic + val TAG: String = Companion::class.java.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/ui/ClearStatusTask.kt b/app/src/main/java/com/nextcloud/ui/ClearStatusTask.kt new file mode 100644 index 000000000000..0c90f59a469b --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/ClearStatusTask.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.accounts.Account +import android.content.Context +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.users.ClearStatusMessageRemoteOperation + +public class ClearStatusTask(val account: Account?, val context: Context?) : Function0 { + override fun invoke(): Boolean = try { + val client = OwnCloudClientFactory.createNextcloudClient(account, context) + + ClearStatusMessageRemoteOperation().execute(client).isSuccess + } catch (e: AccountUtils.AccountNotFoundException) { + Log_OC.e(this, "Error clearing status", e) + + false + } +} diff --git a/app/src/main/java/com/nextcloud/ui/ClientIntegrationScreen.kt b/app/src/main/java/com/nextcloud/ui/ClientIntegrationScreen.kt new file mode 100644 index 000000000000..4f94026af605 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/ClientIntegrationScreen.kt @@ -0,0 +1,173 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2025 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui + +import android.app.Activity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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 com.nextcloud.android.lib.resources.clientintegration.ClientIntegrationUI +import com.nextcloud.android.lib.resources.clientintegration.Element +import com.nextcloud.android.lib.resources.clientintegration.Layout +import com.nextcloud.android.lib.resources.clientintegration.LayoutButton +import com.nextcloud.android.lib.resources.clientintegration.LayoutOrientation +import com.nextcloud.android.lib.resources.clientintegration.LayoutRow +import com.nextcloud.android.lib.resources.clientintegration.LayoutText +import com.nextcloud.android.lib.resources.clientintegration.LayoutURL +import com.nextcloud.utils.extensions.getActivity +import com.owncloud.android.R +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.utils.DisplayUtils + +@Composable +fun ClientIntegrationScreen(clientIntegrationUI: ClientIntegrationUI, baseUrl: String) { + val activity = LocalContext.current.getActivity() + val layoutRows = clientIntegrationUI.root?.rows ?: listOf() + + Scaffold(topBar = { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + IconButton(onClick = { activity?.finish() }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.dialog_close) + ) + } + } + }, modifier = Modifier.fillMaxSize()) { + when (clientIntegrationUI.root?.orientation) { + LayoutOrientation.VERTICAL -> { + LazyColumn(modifier = Modifier.padding(it)) { + items(layoutRows) { row -> + LazyRow { + items(row.children) { element -> + DisplayElement(element, baseUrl, activity) + } + } + } + } + } + + else -> { + LazyRow(modifier = Modifier.padding(it)) { + items(layoutRows) { row -> + LazyColumn { + items(row.children) { element -> + DisplayElement(element, baseUrl, activity) + } + } + } + } + } + } + } +} + +@Composable +private fun DisplayElement(element: Element, baseUrl: String, activity: Activity?) { + when (element) { + is LayoutButton -> Button(onClick = { }) { + Text(element.label) + } + + is LayoutURL -> TextButton({ + openLink(activity, baseUrl, element.url) + }) { Text(element.text) } + + is LayoutText -> Text(element.text) + } +} + +private fun openLink(activity: Activity?, baseUrl: String, relativeUrl: String) { + activity?.let { + DisplayUtils.startLinkIntent(activity, baseUrl + relativeUrl) + } +} + +@Composable +@Preview +private fun ClientIntegrationScreenPreviewVertical() { + val clientIntegrationUI = ClientIntegrationUI( + OCCapability.CLIENT_INTEGRATION_VERSION, + Layout( + LayoutOrientation.VERTICAL, + mutableListOf( + LayoutRow( + listOf(LayoutButton("Click", "Primary"), LayoutText("123")) + ), + LayoutRow( + listOf(LayoutButton("Click2", "Primary")) + ), + LayoutRow( + listOf(LayoutURL("Analytics report created", "https://nextcloud.com")) + ) + ) + ) + ) + + ClientIntegrationScreen( + clientIntegrationUI, + "http://nextcloud.local" + ) +} + +@Composable +@Preview +private fun ClientIntegrationScreenPreviewHorizontal() { + val clientIntegrationUI = ClientIntegrationUI( + OCCapability.CLIENT_INTEGRATION_VERSION, + Layout( + LayoutOrientation.HORIZONTAL, + mutableListOf( + LayoutRow( + listOf(LayoutButton("Click", "Primary"), LayoutText("123")) + ), + LayoutRow( + listOf(LayoutButton("Click2", "Primary")) + ), + LayoutRow( + listOf(LayoutURL("Analytics report created", "https://nextcloud.com")) + ) + ) + ) + ) + + ClientIntegrationScreen(clientIntegrationUI, "http://nextcloud.local") +} + +@Composable +@Preview +private fun ClientIntegrationScreenPreviewEmpty() { + val clientIntegrationUI = ClientIntegrationUI( + OCCapability.CLIENT_INTEGRATION_VERSION, + Layout( + LayoutOrientation.HORIZONTAL, + emptyList() + ) + ) + + ClientIntegrationScreen(clientIntegrationUI, "http://nextcloud.local") +} diff --git a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt new file mode 100644 index 000000000000..766809e8247c --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt @@ -0,0 +1,398 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 ZetaTom + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.drawable.LayerDrawable +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.NominatimClient +import com.nextcloud.client.account.User +import com.nextcloud.client.di.Injectable +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.logFileSize +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.PreviewImageDetailsFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.ItemizedIconOverlay +import org.osmdroid.views.overlay.ItemizedIconOverlay.OnItemGestureListener +import org.osmdroid.views.overlay.OverlayItem +import java.lang.Long.max +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject +import kotlin.math.pow +import kotlin.math.roundToInt + +class ImageDetailFragment : + Fragment(), + Injectable { + private lateinit var binding: PreviewImageDetailsFragmentBinding + private lateinit var file: OCFile + private lateinit var user: User + private lateinit var metadata: ImageMetadata + private lateinit var nominatimClient: NominatimClient + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + private val tag = "ImageDetailFragment" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = PreviewImageDetailsFragmentBinding.inflate(layoutInflater, container, false) + + binding.fileDetailsIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + requireContext(), + R.drawable.outline_image_24, + ColorRole.ON_BACKGROUND + ) + ) + + binding.cameraInformationIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + requireContext(), + R.drawable.outline_camera_24, + ColorRole.ON_BACKGROUND + ) + ) + + val arguments = arguments ?: throw IllegalStateException("arguments are mandatory") + file = arguments.getParcelableArgument(ARG_FILE, OCFile::class.java)!! + user = arguments.getParcelableArgument(ARG_USER, User::class.java)!! + + if (savedInstanceState != null) { + file = savedInstanceState.getParcelableArgument(ARG_FILE, OCFile::class.java)!! + user = savedInstanceState.getParcelableArgument(ARG_USER, User::class.java)!! + metadata = savedInstanceState.getParcelableArgument(ARG_METADATA, ImageMetadata::class.java)!! + } + + nominatimClient = NominatimClient( + getString(R.string.osm_geocoder_url), + getString(R.string.osm_geocoder_contact) + ) + + return binding.root + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + file.logFileSize(tag) + outState.putParcelable(ARG_FILE, file) + outState.putParcelable(ARG_USER, user) + outState.putParcelable(ARG_METADATA, metadata) + } + + override fun onStart() { + super.onStart() + gatherMetadata() + setupFragment() + } + + @SuppressLint("LongMethod") + private fun setupFragment() { + binding.fileInformationTime.text = metadata.date + + // detailed file information + val fileInformation = mutableListOf() + if ((metadata.length ?: 0) > 0 && (metadata.width ?: 0) > 0) { + try { + @Suppress("MagicNumber") + val pxlCount = when (val res = metadata.length!! * metadata.width!!.toLong()) { + in 0..999999 -> "%.2f".format(res / 1000000f) + in 1000000..9999999 -> "%.1f".format(res / 1000000f) + else -> (res / 1000000).toString() + } + + fileInformation.add(String.format(getString(R.string.image_preview_unit_megapixel), pxlCount)) + fileInformation.add("${metadata.width!!} × ${metadata.length!!}") + } catch (_: NumberFormatException) { + } + } + metadata.fileSize?.let { fileInformation.add(it) } + + if (fileInformation.isNotEmpty()) { + binding.fileInformationDetails.text = fileInformation.joinToString(separator = TEXT_SEP) + binding.fileInformation.visibility = View.VISIBLE + } + + setImageTakenConditions() + + // initialise map and address views + metadata.location?.let { location -> + initMap(location.first, location.second) + binding.imageLocation.visibility = View.VISIBLE + + // launch reverse geocoding request + lifecycleScope.launch(Dispatchers.IO) { + val geocodingResult = nominatimClient.reverseGeocode(location.first, location.second) + if (geocodingResult != null) { + withContext(Dispatchers.Main) { + binding.imageLocationText.visibility = View.VISIBLE + binding.imageLocationText.text = geocodingResult.displayName + } + } + } + } + } + + private fun setImageTakenConditions() { + // camera make and model + val makeModel = if (metadata.make?.let { metadata.model?.contains(it) } == false) { + "${metadata.make} ${metadata.model}" + } else { + metadata.model ?: metadata.make + } + + if (metadata.make == null || metadata.model?.contains(metadata.make!!) == true) { + binding.imgTCMakeModel.text = metadata.model + } else { + binding.imgTCMakeModel.text = String.format( + getString(R.string.make_model), + metadata.make, + metadata.model + ) + } + + // image taking conditions + val imageTakingConditions = mutableListOf() + metadata.aperture?.let { + imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_fnumber), it)) + } + metadata.exposure?.let { + imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_seconds), it)) + } + metadata.focalLen?.let { + imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_millimetres), it)) + } + metadata.iso?.let { + imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_iso), it)) + } + + if (imageTakingConditions.isNotEmpty() && makeModel != null) { + binding.imgTCMakeModel.text = makeModel + binding.imgTCConditions.text = imageTakingConditions.joinToString(separator = TEXT_SEP) + binding.imgTC.visibility = View.VISIBLE + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun initMap(latitude: Double, longitude: Double, zoom: Double = 13.0) { + // required for OpenStreetMap + Configuration.getInstance().userAgentValue = MainApp.getUserAgent() + + val location = GeoPoint(latitude, longitude) + + binding.imageLocationMap.apply { + setTileSource(TileSourceFactory.MAPNIK) + + // set expected boundaries + setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0) + isVerticalMapRepetitionEnabled = false + minZoomLevel = 2.0 + maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble() + + // initial location + controller.setCenter(location) + controller.setZoom(zoom) + + // scale labels to be legible + isTilesScaledToDpi = true + setZoomRounding(true) + + // hide zoom buttons + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + + // enable multi-touch zoom + setMultiTouchControls(true) + setOnTouchListener { v, _ -> + v.parent.requestDisallowInterceptTouchEvent(true) + false + } + + val markerOverlay = ItemizedIconOverlay( + mutableListOf(OverlayItem(null, null, location)), + imagePinDrawable(context), + markerOnGestureListener(latitude, longitude), + context + ) + + overlays.add(markerOverlay) + + onResume() + } + + // add copyright notice + binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice + } + + @VisibleForTesting + fun hideMap() { + binding.imageLocationMap.visibility = View.GONE + } + + @SuppressLint("SimpleDateFormat") + private fun gatherMetadata() { + val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) + var timestamp = max(file.modificationTimestamp, file.creationTimestamp) + if (file.isDown) { + val exif = androidx.exifinterface.media.ExifInterface(file.storagePath) + var length = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_LENGTH)?.toInt() + var width = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_WIDTH)?.toInt() + var exposure = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_SHUTTER_SPEED_VALUE) + + // get timestamp from date string + exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_DATETIME)?.let { + timestamp = SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time ?: timestamp + } + + // format exposure string + if (exposure == null) { + exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_EXPOSURE_TIME)?.let { + exposure = "1/" + (1 / it.toDouble()).toInt() + } + } else if ("/" in exposure!!) { + try { + exposure!!.split("/").also { + exposure = "1/" + 2f.pow(it[0].toFloat() / it[1].toFloat()).roundToInt() + } + } catch (_: NumberFormatException) { + } + } + + // determine size if not contained in exif data + if ((width ?: 0) <= 0 || (length ?: 0) <= 0) { + val res = BitmapUtils.getImageResolution(file.storagePath) + width = res[0] + length = res[1] + } + + metadata = ImageMetadata( + fileSize = fileSize, + length = length, + width = width, + exposure = exposure, + date = formatDate(timestamp), + location = exif.latLong?.let { Pair(it[0], it[1]) }, + aperture = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_F_NUMBER), + focalLen = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), + make = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MAKE), + model = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MODEL), + iso = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_ISO_SPEED) ?: exif.getAttribute( + androidx.exifinterface.media.ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY + ) + ) + } else { + // get metadata from server + val location = if (file.geoLocation == null) { + null + } else { + Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude) + } + metadata = ImageMetadata( + fileSize = fileSize, + date = formatDate(timestamp), + location = location, + width = file.imageDimension?.width?.toInt(), + length = file.imageDimension?.height?.toInt() + ) + } + } + + @SuppressLint("SimpleDateFormat") + private fun formatDate(timestamp: Long): String = buildString { + append(SimpleDateFormat("EEEE").format(timestamp)) + append(TEXT_SEP) + append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp)) + append(TEXT_SEP) + append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) + } + + private fun imagePinDrawable(context: Context): LayerDrawable { + val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable + + val bitmap = + ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) + BitmapUtils.bitmapToCircularBitmapDrawable(resources, bitmap)?.let { + drawable.setDrawable(1, it) + } + + return drawable + } + + /** + * OnItemGestureListener for marker in MapView. + */ + private fun markerOnGestureListener(latitude: Double, longitude: Double) = + object : OnItemGestureListener { + override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { + val intent = Intent(Intent.ACTION_VIEW, "geo:0,0?q=$latitude,$longitude".toUri()) + DisplayUtils.startIntentIfAppAvailable(intent, activity, R.string.no_map_app_availble) + return true + } + + override fun onItemLongPress(index: Int, item: OverlayItem): Boolean = false + } + + @Parcelize + private data class ImageMetadata( + val fileSize: String? = null, + val date: String? = null, + val length: Int? = null, + val width: Int? = null, + val exposure: String? = null, + val aperture: String? = null, + val focalLen: String? = null, + val iso: String? = null, + val make: String? = null, + val model: String? = null, + val location: Pair? = null + ) : Parcelable + + companion object { + private const val ARG_FILE = "FILE" + private const val ARG_USER = "USER" + private const val ARG_METADATA = "METADATA" + private const val TEXT_SEP = " • " + private const val SCROLL_LIMIT = 80.0 + + @JvmStatic + fun newInstance(file: OCFile, user: User): ImageDetailFragment = ImageDetailFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_FILE, file) + putParcelable(ARG_USER, user) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/RetrieveStatus.kt b/app/src/main/java/com/nextcloud/ui/RetrieveStatus.kt new file mode 100644 index 000000000000..8231f076d1a6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/RetrieveStatus.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui + +import com.nextcloud.client.account.User +import com.nextcloud.client.network.ClientFactory +import com.owncloud.android.lib.resources.users.GetStatusRemoteOperation +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.lib.resources.users.StatusType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException + +suspend fun retrieveUserStatus(user: User, clientFactory: ClientFactory): Status = withContext(Dispatchers.IO) { + try { + val client = clientFactory.createNextcloudClient(user) + val result = GetStatusRemoteOperation().execute(client) + if (result.isSuccess && result.resultData is Status) { + result.resultData as Status + } else { + offlineStatus() + } + } catch (e: ClientFactory.CreationException) { + offlineStatus() + } catch (e: IOException) { + offlineStatus() + } +} + +private fun offlineStatus() = Status(StatusType.OFFLINE, "", "", -1) diff --git a/app/src/main/java/com/nextcloud/ui/SetOnlineStatusBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/SetOnlineStatusBottomSheet.kt new file mode 100644 index 000000000000..b917d5706fe6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/SetOnlineStatusBottomSheet.kt @@ -0,0 +1,183 @@ +/* + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2020 Nextcloud GmbH + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.ui + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.card.MaterialCardView +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.di.Injectable +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.R +import com.owncloud.android.databinding.SetOnlineStatusBottomSheetBinding +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.lib.resources.users.StatusType +import com.owncloud.android.ui.activity.BaseActivity +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.CapabilityUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SetOnlineStatusBottomSheet(val currentStatus: Status?) : + BottomSheetDialogFragment(R.layout.set_online_status_bottom_sheet), + Injectable { + + private lateinit var binding: SetOnlineStatusBottomSheetBinding + + private lateinit var accountManager: UserAccountManager + + @Inject + lateinit var asyncRunner: AsyncRunner + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + accountManager = (activity as BaseActivity).userAccountManager + + currentStatus?.let { + updateCurrentStatusViews(it) + } + + setupStatusViews() + + viewThemeUtils.platform.themeDialog(binding.root) + + val capability = CapabilityUtils.getCapability(context) + val busyStatus = capability.userStatusSupportsBusy.isTrue + binding.busyStatus.setVisibleIf(busyStatus) + } + + private fun setupStatusViews() { + val statuses = listOf( + binding.onlineStatus to StatusType.ONLINE, + binding.awayStatus to StatusType.AWAY, + binding.busyStatus to StatusType.BUSY, + binding.dndStatus to StatusType.DND, + binding.invisibleStatus to StatusType.INVISIBLE + ) + + statuses.forEach { (view, status) -> + view.setOnClickListener { setStatus(status) } + viewThemeUtils.files.themeStatusCardView(view) + } + } + + private fun updateCurrentStatusViews(it: Status) { + visualizeStatus(it.status) + } + + private fun setStatus(statusType: StatusType) { + asyncRunner.postQuickTask( + SetStatusTask( + statusType, + accountManager.currentOwnCloudAccount?.savedAccount, + context + ), + { + if (it) { + dismiss() + } else { + showErrorSnackbar() + } + }, + { + showErrorSnackbar() + } + ) + } + + private fun showErrorSnackbar() { + lifecycleScope.launch(Dispatchers.Main) { + if (!isAdded) { + return@launch + } + + activity?.let { + DisplayUtils.showSnackMessage(it, R.string.set_online_status_bottom_sheet_error_message) + } + clearTopStatus() + } + } + + private fun visualizeStatus(statusType: StatusType) { + clearTopStatus() + val views: Triple = when (statusType) { + StatusType.ONLINE -> Triple(binding.onlineStatus, binding.onlineHeadline, binding.onlineIcon) + + StatusType.AWAY -> Triple(binding.awayStatus, binding.awayHeadline, binding.awayIcon) + + StatusType.BUSY -> Triple(binding.busyStatus, binding.busyHeadline, binding.busyIcon) + + StatusType.DND -> Triple(binding.dndStatus, binding.dndHeadline, binding.dndIcon) + + StatusType.INVISIBLE -> Triple(binding.invisibleStatus, binding.invisibleHeadline, binding.invisibleIcon) + + else -> { + Log.d(TAG, "unknown status") + return + } + } + views.first.isChecked = true + viewThemeUtils.platform.colorTextView(views.second, ColorRole.ON_SECONDARY_CONTAINER) + } + + private fun clearTopStatus() { + val ctx = context ?: return + val defaultColor = ContextCompat.getColor( + ctx, + com.nextcloud.android.common.ui.R.color.high_emphasis_text + ) + + listOf( + binding.onlineHeadline, + binding.awayHeadline, + binding.busyHeadline, + binding.dndHeadline, + binding.invisibleHeadline + ).forEach { it.setTextColor(defaultColor) } + + listOf( + binding.awayIcon, + binding.dndIcon, + binding.invisibleIcon + ).forEach { it.imageTintList = null } + + listOf( + binding.onlineStatus, + binding.awayStatus, + binding.busyStatus, + binding.dndStatus, + binding.invisibleStatus + ).forEach { it.isChecked = false } + } + + companion object { + private val TAG = SetOnlineStatusBottomSheet::class.simpleName + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = SetOnlineStatusBottomSheetBinding.inflate(layoutInflater, container, false) + return binding.root + } +} diff --git a/app/src/main/java/com/nextcloud/ui/SetPredefinedCustomStatusTask.kt b/app/src/main/java/com/nextcloud/ui/SetPredefinedCustomStatusTask.kt new file mode 100644 index 000000000000..2cb5b5ea3d08 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/SetPredefinedCustomStatusTask.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.accounts.Account +import android.content.Context +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.users.SetPredefinedCustomStatusMessageRemoteOperation + +class SetPredefinedCustomStatusTask( + val messageId: String, + val clearAt: Long?, + val account: Account?, + val context: Context? +) : Function0 { + override fun invoke(): Boolean = try { + val client = OwnCloudClientFactory.createNextcloudClient(account, context) + + SetPredefinedCustomStatusMessageRemoteOperation(messageId, clearAt).execute(client).isSuccess + } catch (e: AccountUtils.AccountNotFoundException) { + Log_OC.e(this, "Error setting predefined status", e) + + false + } +} diff --git a/app/src/main/java/com/nextcloud/ui/SetStatusMessageBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/SetStatusMessageBottomSheet.kt new file mode 100644 index 000000000000..86efd789df05 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/SetStatusMessageBottomSheet.kt @@ -0,0 +1,363 @@ +/* + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2020 Nextcloud GmbH + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.AdapterView +import android.widget.AdapterView.OnItemSelectedListener +import android.widget.ArrayAdapter +import androidx.annotation.VisibleForTesting +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.di.Injectable +import com.owncloud.android.R +import com.owncloud.android.databinding.SetStatusMessageBottomSheetBinding +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.lib.resources.users.ClearAt +import com.owncloud.android.lib.resources.users.PredefinedStatus +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.ui.activity.BaseActivity +import com.owncloud.android.ui.adapter.PredefinedStatusClickListener +import com.owncloud.android.ui.adapter.PredefinedStatusListAdapter +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import com.vanniktech.emoji.EmojiManager +import com.vanniktech.emoji.EmojiPopup +import com.vanniktech.emoji.google.GoogleEmojiProvider +import com.vanniktech.emoji.installDisableKeyboardInput +import com.vanniktech.emoji.installForceSingleEmoji +import java.util.Calendar +import java.util.Locale +import javax.inject.Inject + +private const val POS_DONT_CLEAR = 0 +private const val POS_FIFTEEN_MINUTES = 1 +private const val POS_HALF_AN_HOUR = 2 +private const val POS_AN_HOUR = 3 +private const val POS_FOUR_HOURS = 4 +private const val POS_TODAY = 5 +private const val POS_END_OF_WEEK = 6 + +private const val ONE_SECOND_IN_MILLIS = 1000 +private const val ONE_MINUTE_IN_SECONDS = 60 +private const val THIRTY_MINUTES = 30 +private const val FIFTEEN_MINUTES = 15 +private const val FOUR_HOURS = 4 +private const val LAST_HOUR_OF_DAY = 23 +private const val LAST_MINUTE_OF_HOUR = 59 +private const val LAST_SECOND_OF_MINUTE = 59 + +private const val CLEAR_AT_TYPE_PERIOD = "period" +private const val CLEAR_AT_TYPE_END_OF = "end-of" + +class SetStatusMessageBottomSheet(val user: User, val currentStatus: Status?) : + BottomSheetDialogFragment(R.layout.set_status_message_bottom_sheet), + PredefinedStatusClickListener, + Injectable { + + private lateinit var binding: SetStatusMessageBottomSheetBinding + + private lateinit var accountManager: UserAccountManager + private lateinit var predefinedStatus: ArrayList + private lateinit var adapter: PredefinedStatusListAdapter + private var selectedPredefinedMessageId: String? = null + private var clearAt: Long? = -1 + private lateinit var popup: EmojiPopup + + @Inject + lateinit var arbitraryDataProvider: ArbitraryDataProvider + + @Inject + lateinit var asyncRunner: AsyncRunner + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val json = arbitraryDataProvider.getValue(user, ArbitraryDataProvider.PREDEFINED_STATUS) + + if (json.isNotEmpty()) { + val myType = object : TypeToken>() {}.type + predefinedStatus = Gson().fromJson(json, myType) + } + + EmojiManager.install(GoogleEmojiProvider()) + } + + @SuppressLint("DefaultLocale") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + accountManager = (activity as BaseActivity).userAccountManager + + currentStatus?.let { + updateCurrentStatusViews(it) + } + + adapter = PredefinedStatusListAdapter(this, requireContext()) + if (this::predefinedStatus.isInitialized) { + adapter.list = predefinedStatus + } + binding.predefinedStatusList.adapter = adapter + binding.predefinedStatusList.layoutManager = LinearLayoutManager(context) + + binding.clearStatus.setOnClickListener { clearStatus() } + binding.setStatus.setOnClickListener { setStatusMessage() } + binding.emoji.setOnClickListener { popup.show() } + + popup = EmojiPopup(view, binding.emoji, onEmojiClickListener = { _ -> + popup.dismiss() + binding.emoji.clearFocus() + val imm: InputMethodManager = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as + InputMethodManager + imm.hideSoftInputFromWindow(binding.emoji.windowToken, 0) + }) + binding.emoji.installForceSingleEmoji() + binding.emoji.installDisableKeyboardInput(popup) + + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + adapter.add(getString(R.string.dontClear)) + adapter.add(getString(R.string.fifteenMinutes)) + adapter.add(getString(R.string.thirtyMinutes)) + adapter.add(getString(R.string.oneHour)) + adapter.add(getString(R.string.fourHours)) + adapter.add(getString(R.string.today)) + adapter.add(getString(R.string.thisWeek)) + + binding.clearStatusAfterSpinner.apply { + this.adapter = adapter + onItemSelectedListener = object : OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { + setClearStatusAfterValue(position) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + // nothing to do + } + } + } + + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.clearStatus) + viewThemeUtils.material.colorMaterialButtonPrimaryTonal(binding.setStatus) + viewThemeUtils.material.colorTextInputLayout(binding.customStatusInputContainer) + + viewThemeUtils.platform.themeDialog(binding.root) + } + + private fun updateCurrentStatusViews(it: Status) { + if (it.icon.isNullOrBlank()) { + binding.emoji.setText("😀") + } else { + binding.emoji.setText(it.icon) + } + + binding.customStatusInput.text?.clear() + binding.customStatusInput.setText(it.message) + + if (it.clearAt > 0) { + binding.clearStatusAfterSpinner.visibility = View.GONE + binding.remainingClearTime.apply { + binding.clearStatusMessageTextView.text = getString(R.string.clear) + visibility = View.VISIBLE + text = DisplayUtils.getRelativeTimestamp(context, it.clearAt * ONE_SECOND_IN_MILLIS, true) + .toString() + .replaceFirstChar { it.lowercase(Locale.getDefault()) } + setOnClickListener { + visibility = View.GONE + binding.clearStatusAfterSpinner.visibility = View.VISIBLE + binding.clearStatusMessageTextView.text = getString(R.string.clear_status_after) + } + } + } + } + + private fun setClearStatusAfterValue(item: Int) { + clearAt = when (item) { + POS_DONT_CLEAR -> null + + // don't clear + POS_FIFTEEN_MINUTES -> { + // 15 minutes + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + FIFTEEN_MINUTES * ONE_MINUTE_IN_SECONDS + } + + POS_HALF_AN_HOUR -> { + // 30 minutes + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + THIRTY_MINUTES * ONE_MINUTE_IN_SECONDS + } + + POS_AN_HOUR -> { + // one hour + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + ONE_MINUTE_IN_SECONDS * ONE_MINUTE_IN_SECONDS + } + + POS_FOUR_HOURS -> { + // four hours + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + + FOUR_HOURS * ONE_MINUTE_IN_SECONDS * ONE_MINUTE_IN_SECONDS + } + + POS_TODAY -> { + // today + val date = getLastSecondOfToday() + dateToSeconds(date) + } + + POS_END_OF_WEEK -> { + // end of week + val date = getLastSecondOfToday() + while (date.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY) { + date.add(Calendar.DAY_OF_YEAR, 1) + } + dateToSeconds(date) + } + + else -> clearAt + } + } + + private fun clearAtToUnixTime(clearAt: ClearAt?): Long = when { + clearAt?.type == CLEAR_AT_TYPE_PERIOD -> { + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + clearAt.time.toLong() + } + + clearAt?.type == CLEAR_AT_TYPE_END_OF && clearAt.time == "day" -> { + val date = getLastSecondOfToday() + dateToSeconds(date) + } + + else -> -1 + } + + private fun getLastSecondOfToday(): Calendar { + val date = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, LAST_HOUR_OF_DAY) + set(Calendar.MINUTE, LAST_MINUTE_OF_HOUR) + set(Calendar.SECOND, LAST_SECOND_OF_MINUTE) + } + return date + } + + private fun dateToSeconds(date: Calendar) = date.timeInMillis / ONE_SECOND_IN_MILLIS + + private fun clearStatus() { + asyncRunner.postQuickTask( + ClearStatusTask(accountManager.currentOwnCloudAccount?.savedAccount, context), + { dismiss(it) } + ) + } + + private fun setStatusMessage() { + if (selectedPredefinedMessageId != null) { + asyncRunner.postQuickTask( + SetPredefinedCustomStatusTask( + selectedPredefinedMessageId!!, + clearAt, + accountManager.currentOwnCloudAccount?.savedAccount, + context + ), + { dismiss(it) } + ) + } else { + asyncRunner.postQuickTask( + SetUserDefinedCustomStatusTask( + binding.customStatusInput.text.toString(), + binding.emoji.text.toString(), + clearAt, + accountManager.currentOwnCloudAccount?.savedAccount, + context + ), + { dismiss(it) } + ) + } + } + + private fun dismiss(boolean: Boolean) { + if (boolean) { + dismiss() + } else { + DisplayUtils.showSnackMessage(view, view?.resources?.getString(R.string.error_setting_status_message)) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = SetStatusMessageBottomSheetBinding.inflate(layoutInflater, container, false) + return binding.root + } + + override fun onClick(predefinedStatus: PredefinedStatus) { + val oldListener = binding.clearStatusAfterSpinner.onItemSelectedListener + binding.clearStatusAfterSpinner.onItemSelectedListener = null + + selectedPredefinedMessageId = predefinedStatus.id + clearAt = clearAtToUnixTime(predefinedStatus.clearAt) + binding.emoji.setText(predefinedStatus.icon) + binding.customStatusInput.text?.clear() + binding.customStatusInput.text?.append(predefinedStatus.message) + + binding.remainingClearTime.visibility = View.GONE + binding.clearStatusAfterSpinner.visibility = View.VISIBLE + binding.clearStatusMessageTextView.text = getString(R.string.clear_status_after) + + val clearAt = predefinedStatus.clearAt + if (clearAt == null) { + binding.clearStatusAfterSpinner.setSelection(0) + } else { + when (clearAt.type) { + CLEAR_AT_TYPE_PERIOD -> updateClearAtViewsForPeriod(clearAt) + CLEAR_AT_TYPE_END_OF -> updateClearAtViewsForEndOf(clearAt) + } + } + + setClearStatusAfterValue(binding.clearStatusAfterSpinner.selectedItemPosition) + binding.clearStatusAfterSpinner.onItemSelectedListener = oldListener + } + + private fun updateClearAtViewsForPeriod(clearAt: ClearAt) { + when (clearAt.time) { + "900" -> binding.clearStatusAfterSpinner.setSelection(POS_FIFTEEN_MINUTES) + "1800" -> binding.clearStatusAfterSpinner.setSelection(POS_HALF_AN_HOUR) + "3600" -> binding.clearStatusAfterSpinner.setSelection(POS_AN_HOUR) + "14400" -> binding.clearStatusAfterSpinner.setSelection(POS_FOUR_HOURS) + else -> binding.clearStatusAfterSpinner.setSelection(POS_DONT_CLEAR) + } + } + + private fun updateClearAtViewsForEndOf(clearAt: ClearAt) { + when (clearAt.time) { + "day" -> binding.clearStatusAfterSpinner.setSelection(POS_TODAY) + "week" -> binding.clearStatusAfterSpinner.setSelection(POS_END_OF_WEEK) + else -> binding.clearStatusAfterSpinner.setSelection(POS_DONT_CLEAR) + } + } + + @SuppressLint("NotifyDataSetChanged") + @VisibleForTesting + fun setPredefinedStatus(predefinedStatus: ArrayList) { + this.predefinedStatus = predefinedStatus + if (this::adapter.isInitialized) { + adapter.list = predefinedStatus + binding.predefinedStatusList.adapter?.notifyDataSetChanged() + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/SetStatusTask.kt b/app/src/main/java/com/nextcloud/ui/SetStatusTask.kt new file mode 100644 index 000000000000..547719093b91 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/SetStatusTask.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.accounts.Account +import android.content.Context +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.users.SetStatusRemoteOperation +import com.owncloud.android.lib.resources.users.StatusType + +class SetStatusTask(val statusType: StatusType, val account: Account?, val context: Context?) : Function0 { + override fun invoke(): Boolean = try { + val client = OwnCloudClientFactory.createNextcloudClient(account, context) + + SetStatusRemoteOperation(statusType).execute(client).isSuccess + } catch (e: AccountUtils.AccountNotFoundException) { + Log_OC.e(this, "Error setting status", e) + + false + } +} diff --git a/app/src/main/java/com/nextcloud/ui/SetUserDefinedCustomStatusTask.kt b/app/src/main/java/com/nextcloud/ui/SetUserDefinedCustomStatusTask.kt new file mode 100644 index 000000000000..a35f01c52da9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/SetUserDefinedCustomStatusTask.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.accounts.Account +import android.content.Context +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.users.SetUserDefinedCustomStatusMessageRemoteOperation + +public class SetUserDefinedCustomStatusTask( + val message: String, + val icon: String, + val clearAt: Long?, + val account: Account?, + val context: Context? +) : Function0 { + override fun invoke(): Boolean { + return try { + val client = OwnCloudClientFactory.createNextcloudClient(account, context) + + return SetUserDefinedCustomStatusMessageRemoteOperation(message, icon, clearAt).execute(client).isSuccess + } catch (e: AccountUtils.AccountNotFoundException) { + Log_OC.e(this, "Error setting user defined custom status", e) + + false + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/SquareLoaderImageView.kt b/app/src/main/java/com/nextcloud/ui/SquareLoaderImageView.kt new file mode 100644 index 000000000000..4395748adab7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/SquareLoaderImageView.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui + +import android.content.Context +import android.util.AttributeSet +import com.elyeproj.loaderviewlibrary.LoaderImageView + +/** + * Square version of loader image. + */ +internal class SquareLoaderImageView : LoaderImageView { + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, widthMeasureSpec) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/behavior/OnScrollBehavior.kt b/app/src/main/java/com/nextcloud/ui/behavior/OnScrollBehavior.kt new file mode 100644 index 000000000000..96dedbb0eef8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/behavior/OnScrollBehavior.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.behavior + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.behavior.HideViewOnScrollBehavior + +class OnScrollBehavior @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + HideViewOnScrollBehavior(context, attrs) { + + override fun onNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + type: Int, + consumed: IntArray + ) { + if (dyConsumed > 0) { + slideOut(child) + } else if (dyConsumed < 0 || dyUnconsumed < 0) { + slideIn(child) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt new file mode 100644 index 000000000000..6afc23a55fa8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt @@ -0,0 +1,166 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui.composeActivity + +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import androidx.activity.viewModels +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.nextcloud.client.assistant.AssistantScreen +import com.nextcloud.client.assistant.AssistantViewModel +import com.nextcloud.client.assistant.chat.ChatViewModel +import com.nextcloud.client.assistant.conversation.ConversationViewModel +import com.nextcloud.client.assistant.conversation.repository.ConversationRemoteRepositoryImpl +import com.nextcloud.client.assistant.repository.local.AssistantLocalRepositoryImpl +import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositoryImpl +import com.nextcloud.client.database.NextcloudDatabase +import com.nextcloud.common.NextcloudClient +import com.nextcloud.ui.ClientIntegrationScreen +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +import com.owncloud.android.databinding.ActivityComposeBinding +import com.owncloud.android.ui.activity.DrawerActivity + +class ComposeActivity : DrawerActivity() { + + lateinit var binding: ActivityComposeBinding + private val composeViewModel: ComposeViewModel by viewModels() + + companion object { + const val DESTINATION = "DESTINATION" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityComposeBinding.inflate(layoutInflater) + setContentView(binding.root) + + val destination = + intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) + ?: ComposeDestination.getAssistantScreen(this) + + setupActivityUIFor(destination) + + binding.composeView.setContent { + MaterialTheme( + colorScheme = viewThemeUtils.getColorScheme(this), + content = { + Content(destination) + } + ) + } + + processText(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + processText(intent) + } + + private fun processText(intent: Intent) { + val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) + if (text.isNullOrEmpty()) { + return + } + + composeViewModel.updateSelectedText(text.toString()) + } + + override fun getMenuItemId(): Int = R.id.nav_assistant + + override fun onResume() { + super.onResume() + highlightNavigationViewItem(menuItemId) + } + + private fun setupActivityUIFor(destination: ComposeDestination) { + if (destination is ComposeDestination.AssistantScreen) { + setupDrawer(menuItemId) + setupToolbarShowOnlyMenuButtonAndTitle(destination.title) { + openDrawer() + } + } else { + setSupportActionBar(null) + findViewById(R.id.appbar)?.let { + it.visibility = View.GONE + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + toggleDrawer() + true + } + + else -> super.onOptionsItemSelected(item) + } + + @Composable + private fun Content(destination: ComposeDestination) { + val currentScreen by ComposeNavigation.currentScreen.collectAsState() + var nextcloudClient by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + ComposeNavigation.navigate(destination) + nextcloudClient = clientRepository.getNextcloudClient() + } + + binding.bottomNavigation.menu.findItem(R.id.nav_assistant).run { + isChecked = true + } + + when (currentScreen) { + is ComposeDestination.AssistantScreen -> { + val dao = NextcloudDatabase.instance().assistantDao() + val sessionId = (currentScreen as? ComposeDestination.AssistantScreen)?.sessionId + val client = nextcloudClient ?: return + val optionalCapability = capabilities + if (optionalCapability.isEmpty) { + return + } + val capability = optionalCapability.get() + val remoteRepository = AssistantRemoteRepositoryImpl(client, capability) + + AssistantScreen( + composeViewModel = composeViewModel, + viewModel = AssistantViewModel( + accountName = userAccountManager.user.accountName, + remoteRepository = remoteRepository, + localRepository = AssistantLocalRepositoryImpl(dao), + sessionIdArg = sessionId + ), + chatViewModel = ChatViewModel(remoteRepository), + conversationViewModel = ConversationViewModel( + remoteRepository = ConversationRemoteRepositoryImpl(client) + ), + activity = this, + capability = capability + ) + } + + is ComposeDestination.ClientIntegrationScreen -> { + binding.bottomNavigation.visibility = View.GONE + val integrationScreen = (currentScreen as ComposeDestination.ClientIntegrationScreen) + ClientIntegrationScreen(integrationScreen.data, nextcloudClient?.baseUri.toString()) + } + + else -> Unit + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt new file mode 100644 index 000000000000..6564d61cd515 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui.composeActivity + +import android.content.Context +import android.os.Parcelable +import com.nextcloud.android.lib.resources.clientintegration.ClientIntegrationUI +import com.owncloud.android.R +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class ComposeDestination(val id: Int) : Parcelable { + @Parcelize + data class AssistantScreen(val title: String, val sessionId: Long?) : ComposeDestination(0) + + @Parcelize + data class ClientIntegrationScreen(val title: String, val data: ClientIntegrationUI) : ComposeDestination(1) + + companion object { + /** + * Creates a assistant screen without selected chat + */ + fun getAssistantScreen(context: Context): AssistantScreen = + AssistantScreen(context.getString(R.string.assistant_screen_top_bar_title), null) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt new file mode 100644 index 000000000000..453bb439b8f2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.composeActivity + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +object ComposeNavigation { + private var root: MutableStateFlow = MutableStateFlow(null) + val currentScreen: StateFlow = root + fun navigate(value: ComposeDestination) = root.update { value } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeProcessTextAlias.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeProcessTextAlias.kt new file mode 100644 index 000000000000..5b280dcc9a02 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeProcessTextAlias.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.composeActivity + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import com.owncloud.android.utils.theme.CapabilityUtils +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ComposeProcessTextAlias @Inject constructor(private val context: Context) { + + fun configure() { + val capability = CapabilityUtils.getCapability(context) + val isAssistantAvailable = capability.assistant.isTrue + + val componentName = ComponentName( + context, + "com.nextcloud.ui.composeActivity.ComposeProcessTextAlias" + ) + + val newState = if (isAssistantAvailable) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + context.packageManager.setComponentEnabledSetting( + componentName, + newState, + PackageManager.DONT_KILL_APP + ) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt new file mode 100644 index 000000000000..86b94a633cd8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.composeActivity + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class ComposeViewModel : ViewModel() { + private val _selectedText = MutableStateFlow(null) + val selectedText: StateFlow = _selectedText + + fun updateSelectedText(value: String) { + _selectedText.update { + value + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/SimpleAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/SimpleAlertDialog.kt new file mode 100644 index 000000000000..738c9ddc1bab --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/SimpleAlertDialog.kt @@ -0,0 +1,84 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui.composeComponents.alertDialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.owncloud.android.R + +@Suppress("LongParameterList") +@Composable +fun SimpleAlertDialog( + title: String, + description: String?, + heightFraction: Float? = null, + content: @Composable (() -> Unit)? = null, + onComplete: () -> Unit, + onDismiss: () -> Unit +) { + val modifier = if (heightFraction != null) { + Modifier + .fillMaxWidth() + .fillMaxHeight(heightFraction) + } else { + Modifier.fillMaxWidth() + } + + AlertDialog( + containerColor = MaterialTheme.colorScheme.surface, + iconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + textContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + onDismissRequest = { onDismiss() }, + title = { + Text(text = title) + }, + text = { + Column(modifier = modifier) { + description?.let { + Text(text = description) + } + + content?.let { + Spacer(modifier = Modifier.height(16.dp)) + + content() + } + } + }, + confirmButton = { + FilledTonalButton(onClick = { + onComplete() + onDismiss() + }) { + Text( + stringResource(id = R.string.common_ok) + ) + } + }, + dismissButton = { + TextButton(onClick = { onDismiss() }) { + Text( + stringResource(id = R.string.common_cancel) + ) + } + } + ) +} diff --git a/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/TaskSelectionAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/TaskSelectionAlertDialog.kt new file mode 100644 index 000000000000..9cae1dd238fb --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/TaskSelectionAlertDialog.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.composeComponents.alertDialog + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskSelectionAlertDialog(taskTypes: List, onDismiss: () -> Unit, onConfirm: (TaskTypeData) -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 6.dp + ) { + LazyColumn( + modifier = Modifier.padding(vertical = 16.dp) + ) { + items(taskTypes) { task -> + TextButton( + onClick = { + onConfirm(task) + onDismiss() + }, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text( + text = task.name, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeComponents/bottomSheet/MoreActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/composeComponents/bottomSheet/MoreActionsBottomSheet.kt new file mode 100644 index 000000000000..94dd7c39b9b6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeComponents/bottomSheet/MoreActionsBottomSheet.kt @@ -0,0 +1,103 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui.composeComponents.bottomSheet + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch + +@SuppressLint("ResourceAsColor") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoreActionsBottomSheet(title: String? = null, actions: List Unit>>, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + ModalBottomSheet( + modifier = Modifier.padding(top = 32.dp), + containerColor = colorScheme.surface, + onDismissRequest = { + onDismiss() + }, + sheetState = sheetState + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxWidth() + .padding(all = 8.dp) + ) { + title?.let { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) { + Text(text = title, fontSize = 18.sp) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + actions.forEach { action -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + onDismiss() + action.third() + } + } + } + .padding(all = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = action.first), + contentDescription = "action icon", + tint = colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(action.second), + fontSize = 16.sp + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt b/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt new file mode 100644 index 000000000000..d7dc8668ad34 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt @@ -0,0 +1,244 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileactions + +import android.content.Context +import android.content.Intent +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.graphics.drawable.PictureDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import com.nextcloud.android.lib.resources.clientintegration.ClientIntegrationUI +import com.nextcloud.android.lib.resources.clientintegration.Element +import com.nextcloud.android.lib.resources.clientintegration.ElementTypeAdapter +import com.nextcloud.android.lib.resources.clientintegration.Endpoint +import com.nextcloud.android.lib.resources.clientintegration.TooltipResponse +import com.nextcloud.client.account.User +import com.nextcloud.common.JSONRequestBody +import com.nextcloud.operations.GetMethod +import com.nextcloud.operations.PostMethod +import com.nextcloud.ui.composeActivity.ComposeActivity +import com.nextcloud.ui.composeActivity.ComposeDestination +import com.nextcloud.utils.GlideHelper +import com.owncloud.android.R +import com.owncloud.android.databinding.FileActionsBottomSheetBinding +import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.ocs.ServerResponse +import com.owncloud.android.lib.resources.status.Method +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.RequestBody +import org.apache.commons.httpclient.HttpStatus +import java.io.IOException + +class ClientIntegration( + private var sheet: FileActionsBottomSheet, + private var user: User, + private var context: Context +) { + + fun inflateClientIntegrationActionView( + endpoint: Endpoint, + layoutInflater: LayoutInflater, + binding: FileActionsBottomSheetBinding, + viewModel: FileActionsViewModel, + viewThemeUtils: ViewThemeUtils + ): View { + val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false) + .apply { + root.setOnClickListener { + if (viewModel.uiState.value is FileActionsViewModel.UiState.LoadedForSingleFile) { + val singleFile = (viewModel.uiState.value as FileActionsViewModel.UiState.LoadedForSingleFile) + + val fileId = singleFile.titleFile?.localId.toString() + val filePath = singleFile.titleFile?.remotePath.toString() + + requestClientIntegration(endpoint, fileId, filePath) + } else { + requestClientIntegration(endpoint, "", "") + } + } + text.text = endpoint.name + + sheet.lifecycleScope.launch(Dispatchers.IO) { + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(user.toOwnCloudAccount(), context) + + val rawDrawable = if (endpoint.icon != null) { + GlideHelper.getDrawable(context, client, client.baseUri.toString() + endpoint.icon)?.mutate() + } else { + null + } + + val tintableDrawable = prepareDrawableForTinting(rawDrawable) ?: getDefaultIconDrawable() + + withContext(Dispatchers.Main) { + tintableDrawable?.let { + val tinted = viewThemeUtils.platform.tintDrawable(context, it) + icon.setImageDrawable(tinted) + } + } + } + } + + return itemBinding.root + } + + @Suppress("ReturnCount") + private fun prepareDrawableForTinting(drawable: Drawable?): Drawable? { + if (drawable == null) { + return null + } + + if (drawable is PictureDrawable) { + val defaultSize = DisplayUtils.convertDpToPixel( + context.resources.getDimension(R.dimen.iconized_single_line_item_icon_size), + context + ).toInt().coerceAtLeast(1) + + val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else defaultSize + val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else defaultSize + + val safeWidth = width.coerceAtLeast(1) + val safeHeight = height.coerceAtLeast(1) + + val bitmap = createBitmap(safeWidth, safeHeight) + val canvas = Canvas(bitmap) + canvas.drawPicture(drawable.picture) + + return bitmap.toDrawable(context.resources) + } + + return drawable + } + + private fun getDefaultIconDrawable(): Drawable? = AppCompatResources.getDrawable(context, R.drawable.ic_activity) + + private fun requestClientIntegration(endpoint: Endpoint, fileId: String, filePath: String) { + sheet.lifecycleScope.launch(Dispatchers.IO) { + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(user.toOwnCloudAccount(), context) + + // construct url + var url = (client.baseUri.toString() + endpoint.url).toUri() + .buildUpon() + .appendQueryParameter("format", "json") + .build() + .toString() + + // Always replace known placeholder in url + url = url.replace("{filePath}", filePath, false) + url = url.replace("{fileId}", fileId, false) + + val method = when (endpoint.method) { + Method.POST -> { + val requestBody = if (endpoint.params?.isNotEmpty() == true) { + val jsonRequestBody = JSONRequestBody() + endpoint.params!!.forEach { + when (it.value) { + "{filePath}" -> jsonRequestBody.put(it.key, filePath) + "{fileId}" -> jsonRequestBody.put(it.key, fileId) + } + } + + jsonRequestBody.get() + } else { + RequestBody.EMPTY + } + + PostMethod(url, true, requestBody) + } + + else -> GetMethod(url, true) + } + + val result = try { + client.execute(method) + } catch (_: IOException) { + showMessage(context.resources.getString(R.string.failed_to_start_action)) + } + val response = method.getResponseBodyAsString() + + try { + val output = parseClientIntegrationResult(response) + if (output.root != null && output.root?.rows != null) { + startClientIntegration(endpoint, output) + } else { + val tooltipResponse = parseTooltipResult(response) + showMessage(tooltipResponse.tooltip) + } + } catch (_: JsonSyntaxException) { + if (result == HttpStatus.SC_OK) { + showMessage(context.resources.getString(R.string.action_triggered)) + } else { + showMessage(context.resources.getString(R.string.failed_to_start_action)) + } + } + sheet.dismiss() + } + } + + private suspend fun showMessage(message: String) = withContext(Dispatchers.Main) { + DisplayUtils.showSnackMessage(sheet.requireActivity(), message) + } + + private fun parseTooltipResult(response: String?): TooltipResponse { + val element: JsonElement = JsonParser.parseString(response) + return Gson() + .fromJson(element, object : TypeToken>() {}) + .ocs + .data + } + + private fun startClientIntegration(endpoint: Endpoint, data: ClientIntegrationUI) { + sheet.lifecycleScope.launch(Dispatchers.IO) { + val integrationScreen = ComposeDestination.ClientIntegrationScreen(endpoint.name, data) + + val bundle = Bundle().apply { + putParcelable(ComposeActivity.DESTINATION, integrationScreen) + } + + val composeActivity = Intent(context, ComposeActivity::class.java).apply { + putExtras(bundle) + } + + context.startActivity(composeActivity) + sheet.dismiss() + } + } + + private fun parseClientIntegrationResult(response: String?): ClientIntegrationUI { + val gson = + GsonBuilder() + .registerTypeHierarchyAdapter(Element::class.java, ElementTypeAdapter()) + .create() + + val element: JsonElement = JsonParser.parseString(response) + return gson + .fromJson(element, object : TypeToken>() {}) + .ocs + .data + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt new file mode 100644 index 000000000000..e8881c8751b7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt @@ -0,0 +1,236 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui.fileactions + +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile + +enum class FileAction( + @param:IdRes val id: Int, + @param:StringRes val title: Int, + @param:DrawableRes val icon: Int? = null +) { + // selection + SELECT_ALL(R.id.action_select_all_action_menu, R.string.select_all, R.drawable.ic_select_all), + SELECT_NONE(R.id.action_deselect_all_action_menu, R.string.deselect_all, R.drawable.ic_select_none), + + // generic file actions + EDIT(R.id.action_edit, R.string.action_edit, R.drawable.ic_edit), + SEE_DETAILS(R.id.action_see_details, R.string.actionbar_see_details, R.drawable.ic_information_outline), + REMOVE_FILE(R.id.action_remove_file, R.string.common_remove, R.drawable.ic_delete), + LEAVE_SHARE(R.id.action_remove_file, R.string.common_leave_this_share, R.drawable.ic_cancel), + + // File moving + RENAME_FILE(R.id.action_rename_file, R.string.common_rename, R.drawable.ic_rename), + MOVE_OR_COPY(R.id.action_move_or_copy, R.string.actionbar_move_or_copy, R.drawable.ic_external), + + // favorites + FAVORITE(R.id.action_favorite, R.string.favorite, R.drawable.ic_star_outline), + UNSET_FAVORITE(R.id.action_unset_favorite, R.string.unset_favorite, R.drawable.ic_star), + + // Uploads and downloads + DOWNLOAD_FILE(R.id.action_download_file, R.string.filedetails_download, R.drawable.ic_cloud_download), + DOWNLOAD_FOLDER(R.id.action_sync_file, R.string.filedetails_sync_file, R.drawable.ic_sync), + CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync, R.drawable.ic_sync_off), + + // File sharing + EXPORT_FILE(R.id.action_export_file, R.string.filedetails_export, R.drawable.ic_export), + SEND_SHARE_FILE(R.id.action_send_share_file, R.string.action_send_share, R.drawable.ic_share), + SEND_FILE(R.id.action_send_file, R.string.common_send, R.drawable.ic_share), + OPEN_FILE_WITH(R.id.action_open_file_with, R.string.actionbar_open_with, R.drawable.ic_external), + STREAM_MEDIA(R.id.action_stream_media, R.string.stream, R.drawable.ic_play_arrow), + SET_AS_WALLPAPER(R.id.action_set_as_wallpaper, R.string.set_picture_as, R.drawable.ic_wallpaper), + + // Encryption + SET_ENCRYPTED(R.id.action_encrypted, R.string.encrypted, R.drawable.ic_encrypt), + UNSET_ENCRYPTED(R.id.action_unset_encrypted, R.string.unset_encrypted, R.drawable.ic_decrypt), + + // locks + UNLOCK_FILE(R.id.action_unlock_file, R.string.unlock_file, R.drawable.ic_lock_open_white), + LOCK_FILE(R.id.action_lock_file, R.string.lock_file, R.drawable.ic_lock), + + // Shortcuts + PIN_TO_HOMESCREEN(R.id.action_pin_to_homescreen, R.string.pin_home, R.drawable.add_to_home_screen), + + // Retry for offline operation + RETRY(R.id.action_retry, R.string.retry, R.drawable.ic_retry); + + constructor(id: Int, title: Int) : this(id, title, null) + + companion object { + /** + * All file actions, in the order they should be displayed + */ + fun getActions(files: Collection): List { + return mutableListOf( + UNLOCK_FILE, + EDIT, + FAVORITE, + UNSET_FAVORITE, + SEE_DETAILS, + LOCK_FILE, + RENAME_FILE, + MOVE_OR_COPY, + DOWNLOAD_FILE, + EXPORT_FILE, + STREAM_MEDIA, + SEND_SHARE_FILE, + SEND_FILE, + OPEN_FILE_WITH, + DOWNLOAD_FOLDER, + CANCEL_SYNC, + SELECT_ALL, + SELECT_NONE, + SET_ENCRYPTED, + UNSET_ENCRYPTED, + SET_AS_WALLPAPER, + PIN_TO_HOMESCREEN, + RETRY + ).apply { + val deleteOrLeaveShareAction = getDeleteOrLeaveShareAction(files) ?: return@apply + add(deleteOrLeaveShareAction) + } + } + + fun getFilePreviewActions(file: OCFile?): List { + val result = mutableSetOf( + R.id.action_rename_file, + R.id.action_sync_file, + R.id.action_move_or_copy, + R.id.action_favorite, + R.id.action_unset_favorite, + R.id.action_pin_to_homescreen + ) + + if (file != null) { + val actionsToHide = getActionsToHide(setOf(file)) + result.removeAll(actionsToHide) + } + + return result.toList() + } + + fun getFileDetailActions(file: OCFile?): List { + val result = mutableSetOf( + R.id.action_lock_file, + R.id.action_unlock_file, + R.id.action_edit, + R.id.action_see_details, + R.id.action_move_or_copy, + R.id.action_stream_media, + R.id.action_send_share_file, + R.id.action_pin_to_homescreen + ) + + if (file?.isFolder == true) { + result.add(R.id.action_send_file) + result.add(R.id.action_sync_file) + } + + if (file?.isAPKorAAB == true) { + result.add(R.id.action_download_file) + result.add(R.id.action_export_file) + } + + if (file != null) { + val actionsToHide = getActionsToHide(setOf(file)) + result.removeAll(actionsToHide) + } + + return result.toList() + } + + fun getFileListActionsToHide(checkedFiles: Set): List { + val result = mutableSetOf() + + if (checkedFiles.any { it.isOfflineOperation }) { + result.addAll( + listOf( + R.id.action_favorite, + R.id.action_move_or_copy, + R.id.action_sync_file, + R.id.action_encrypted, + R.id.action_unset_encrypted, + R.id.action_edit, + R.id.action_download_file, + R.id.action_export_file, + R.id.action_set_as_wallpaper + ) + ) + } + + if (checkedFiles.any { it.isAPKorAAB }) { + result.addAll( + listOf( + R.id.action_send_share_file, + R.id.action_export_file, + R.id.action_sync_file, + R.id.action_download_file + ) + ) + } + + val actionsToHide = getActionsToHide(checkedFiles) + result.addAll(actionsToHide) + + return result.toList() + } + + fun getActionsToHide(files: Set): List { + if (files.isEmpty()) return emptyList() + + val result = mutableListOf() + + if (files.any { !it.canReshare() }) { + result.add(R.id.action_send_share_file) + } + + if (files.any { !it.canRename() }) { + result.add(R.id.action_rename_file) + } + + if (files.any { !it.canMove() }) { + result.add(R.id.action_move_or_copy) + } + + if (files.any { !it.canWrite() }) { + result.add(R.id.action_edit) + } + + if (files.any { it.isRecommendedFile }) { + val allowedForRecommended = setOf( + R.id.action_see_details, + R.id.action_set_as_wallpaper, + R.id.action_pin_to_homescreen, + R.id.action_open_file_with + ) + + val allActions = entries.map { it.id } + result.addAll(allActions - allowedForRecommended) + } + + return result + } + + private fun getDeleteOrLeaveShareAction(files: Collection): FileAction? { + if (files.any { !it.canDeleteOrLeaveShare() }) { + return null + } + + return if (files.any { it.isSharedWithMe }) { + LEAVE_SHARE + } else { + REMOVE_FILE + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt new file mode 100644 index 000000000000..6ebd22edf63e --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt @@ -0,0 +1,379 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui.fileactions + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.IdRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.os.bundleOf +import androidx.core.view.isEmpty +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.android.lib.resources.clientintegration.Endpoint +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.di.ViewModelFactory +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.R +import com.owncloud.android.databinding.FileActionsBottomSheetBinding +import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.resources.files.model.FileLockType +import com.owncloud.android.ui.activity.ComponentsGetter +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.overlay.OverlayManager +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class FileActionsBottomSheet : + BottomSheetDialogFragment(), + Injectable { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var vmFactory: ViewModelFactory + + @Inject + lateinit var currentUserProvider: CurrentAccountProvider + + @Inject + lateinit var storageManager: FileDataStorageManager + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var overlayManager: OverlayManager + + private lateinit var viewModel: FileActionsViewModel + + private var _binding: FileActionsBottomSheetBinding? = null + val binding + get() = _binding!! + + private lateinit var componentsGetter: ComponentsGetter + + private val thumbnailAsyncTasks = mutableListOf() + + private var endpoints: List? = mutableListOf() + + private lateinit var clientIntegration: ClientIntegration + + fun interface ResultListener { + fun onResult(@IdRes actionId: Int) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + viewModel = ViewModelProvider(this, vmFactory)[FileActionsViewModel::class.java] + _binding = FileActionsBottomSheetBinding.inflate(inflater, container, false) + + viewModel.uiState.observe(viewLifecycleOwner, this::handleState) + + viewModel.clickActionId.observe(viewLifecycleOwner) { id -> + dispatchActionClick(id) + } + + viewModel.load(requireArguments(), componentsGetter) + + endpoints = arguments?.getParcelableArrayList(FileActionsViewModel.ARG_ENDPOINTS) + + val bottomSheetDialog = dialog as BottomSheetDialog + bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + bottomSheetDialog.behavior.skipCollapsed = true + + viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE) + + clientIntegration = ClientIntegration(this, currentUserProvider.user, requireContext()) + + return binding.root + } + + private fun handleState(state: FileActionsViewModel.UiState) { + toggleLoadingOrContent(state) + when (state) { + is FileActionsViewModel.UiState.LoadedForSingleFile -> { + loadFileThumbnail(state.titleFile) + if (state.lockInfo != null) { + displayLockInfo(state.lockInfo) + } + displayActions(state.actions) + displayTitle(state.titleFile) + } + + is FileActionsViewModel.UiState.LoadedForMultipleFiles -> { + setMultipleFilesThumbnail() + displayActions(state.actions) + displayTitle(state.fileCount) + } + + FileActionsViewModel.UiState.Loading -> {} + + FileActionsViewModel.UiState.Error -> { + activity?.let { + DisplayUtils.showSnackMessage(it, R.string.error_file_actions) + } + dismissAllowingStateLoss() + } + } + } + + private fun loadFileThumbnail(titleFile: OCFile?) { + titleFile?.let { + DisplayUtils.setThumbnail( + it, + binding.thumbnailLayout.thumbnail, + currentUserProvider.user, + storageManager, + thumbnailAsyncTasks, + false, + context, + binding.thumbnailLayout.thumbnailShimmer, + syncedFolderProvider.preferences, + viewThemeUtils, + overlayManager + ) + } + } + + private fun setMultipleFilesThumbnail() { + context?.let { + val drawable = viewThemeUtils.platform.tintDrawable(it, R.drawable.file_multiple, ColorRole.PRIMARY) + binding.thumbnailLayout.thumbnail.setImageDrawable(drawable) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onAttach(context: Context) { + super.onAttach(context) + require(context is ComponentsGetter) { + "Context is not a ComponentsGetter" + } + this.componentsGetter = context + } + + fun setResultListener( + fragmentManager: FragmentManager, + lifecycleOwner: LifecycleOwner, + listener: ResultListener + ): FileActionsBottomSheet { + fragmentManager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner) { _, result -> + @IdRes val actionId = result.getInt(RESULT_KEY_ACTION_ID, -1) + if (actionId != -1) { + listener.onResult(actionId) + } + } + return this + } + + private fun toggleLoadingOrContent(state: FileActionsViewModel.UiState) { + if (state is FileActionsViewModel.UiState.Loading) { + binding.bottomSheetLoading.isVisible = true + binding.bottomSheetHeader.isVisible = false + viewThemeUtils.platform.colorCircularProgressBar(binding.bottomSheetLoading, ColorRole.PRIMARY) + } else { + binding.bottomSheetLoading.isVisible = false + binding.bottomSheetHeader.isVisible = true + } + } + + private fun displayActions(actions: List) { + if (binding.fileActionsList.isEmpty()) { + actions.forEach { action -> + val view = inflateActionView(action) + binding.fileActionsList.addView(view) + } + + // add client integration + if (endpoints != null) { + for (val e in endpoints) { + val ui = clientIntegration.inflateClientIntegrationActionView( + e, + layoutInflater, + binding, + viewModel, + viewThemeUtils + ) + binding.fileActionsList.addView(ui) + } + } + } + } + + private fun displayTitle(titleFile: OCFile?) { + val decryptedFileName = titleFile?.decryptedFileName + if (decryptedFileName != null) { + val isFolder = titleFile.isFolder + val isRTL = DisplayUtils.isRTL() + val (base, ext) = FileStorageUtils.getFilenameAndExtension(decryptedFileName, isFolder, isRTL) + val titleMaxWidth = DisplayUtils.convertDpToPixel( + requireContext().resources.configuration.screenWidthDp.times(FILENAME_MAX_WIDTH_PERCENTAGE).toFloat(), + context + ) + + binding.title.maxWidth = titleMaxWidth + binding.title.text = base + binding.extension.setVisibleIf(!isFolder) + if (!isFolder) { + binding.extension.text = ext + } + } else { + binding.title.isVisible = false + binding.extension.isVisible = false + } + } + + private fun displayLockInfo(lockInfo: FileActionsViewModel.LockInfo) { + val view = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false) + .apply { + val textColor = ColorStateList.valueOf(resources.getColor(R.color.secondary_text_color, null)) + root.isClickable = false + text.setTextColor(textColor) + text.text = getLockedByText(lockInfo) + if (lockInfo.lockedUntil != null) { + textLine2.text = getLockedUntilText(lockInfo) + textLine2.isVisible = true + } + if (lockInfo.lockType != FileLockType.COLLABORATIVE) { + showLockAvatar(lockInfo) + } + } + binding.fileActionsList.addView(view.root) + } + + private fun FileActionsBottomSheetItemBinding.showLockAvatar(lockInfo: FileActionsViewModel.LockInfo) { + val listener = object : AvatarGenerationListener { + override fun avatarGenerated(avatarDrawable: Drawable?, callContext: Any?) { + icon.setImageDrawable(avatarDrawable) + } + + override fun shouldCallGeneratedCallback(tag: String?, callContext: Any?): Boolean = false + } + DisplayUtils.setAvatar( + currentUserProvider.user, + lockInfo.lockedBy, + listener, + resources.getDimension(R.dimen.list_item_avatar_icon_radius), + resources, + this, + requireContext() + ) + } + + private fun getLockedByText(lockInfo: FileActionsViewModel.LockInfo): CharSequence { + val resource = when (lockInfo.lockType) { + FileLockType.COLLABORATIVE -> R.string.locked_by_app + else -> R.string.locked_by + } + return DisplayUtils.createTextWithSpan( + getString(resource, lockInfo.lockedBy), + lockInfo.lockedBy, + StyleSpan(Typeface.BOLD) + ) + } + + private fun getLockedUntilText(lockInfo: FileActionsViewModel.LockInfo): CharSequence { + val relativeTimestamp = DisplayUtils.getRelativeTimestamp(context, lockInfo.lockedUntil!!, true) + return getString(R.string.lock_expiration_info, relativeTimestamp) + } + + private fun displayTitle(fileCount: Int) { + binding.title.text = resources.getQuantityString(R.plurals.file_list__footer__file, fileCount, fileCount) + } + + private fun inflateActionView(action: FileAction): View { + val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false) + .apply { + root.setOnClickListener { + viewModel.onClick(action) + } + text.setText(action.title) + if (action.icon != null) { + val drawable = + viewThemeUtils.platform.tintDrawable( + requireContext(), + AppCompatResources.getDrawable(requireContext(), action.icon)!! + ) + icon.setImageDrawable(drawable) + } + } + return itemBinding.root + } + + private fun dispatchActionClick(id: Int?) { + if (id != null) { + setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_ACTION_ID to id)) + parentFragmentManager.clearFragmentResultListener(REQUEST_KEY) + dismiss() + } + } + + companion object { + private const val REQUEST_KEY = "REQUEST_KEY_ACTION" + private const val RESULT_KEY_ACTION_ID = "RESULT_KEY_ACTION_ID" + private const val FILENAME_MAX_WIDTH_PERCENTAGE = 0.6 + + @JvmStatic + @JvmOverloads + fun newInstance( + file: OCFile, + isOverflow: Boolean, + @IdRes + additionalToHide: List? = null + ): FileActionsBottomSheet = newInstance(1, listOf(file), isOverflow, additionalToHide, true, emptyList()) + + @JvmStatic + @JvmOverloads + fun newInstance( + numberOfAllFiles: Int, + files: Collection, + isOverflow: Boolean, + @IdRes + additionalToHide: List? = null, + inSingleFileFragment: Boolean = false, + endpoints: List + ): FileActionsBottomSheet = FileActionsBottomSheet().apply { + val argsBundle = bundleOf( + FileActionsViewModel.ARG_ALL_FILES_COUNT to numberOfAllFiles, + FileActionsViewModel.ARG_FILES to ArrayList(files), + FileActionsViewModel.ARG_IS_OVERFLOW to isOverflow, + FileActionsViewModel.ARG_IN_SINGLE_FILE_FRAGMENT to inSingleFileFragment, + FileActionsViewModel.ARG_ENDPOINTS to endpoints + ) + additionalToHide?.let { + argsBundle.putIntArray(FileActionsViewModel.ARG_ADDITIONAL_FILTER, additionalToHide.toIntArray()) + } + arguments = argsBundle + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsViewModel.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsViewModel.kt new file mode 100644 index 000000000000..ec4b48cd5a28 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsViewModel.kt @@ -0,0 +1,148 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.ui.fileactions + +import android.os.Bundle +import androidx.annotation.IdRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.logger.Logger +import com.nextcloud.utils.TimeConstants +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.FileMenuFilter +import com.owncloud.android.lib.resources.files.model.FileLockType +import com.owncloud.android.ui.activity.ComponentsGetter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +class FileActionsViewModel @Inject constructor( + private val currentAccountProvider: CurrentAccountProvider, + private val filterFactory: FileMenuFilter.Factory, + private val logger: Logger +) : ViewModel() { + + data class LockInfo(val lockType: FileLockType, val lockedBy: String, val lockedUntil: Long?) + + sealed interface UiState { + object Loading : UiState + object Error : UiState + data class LoadedForSingleFile( + val actions: List, + val titleFile: OCFile?, + val lockInfo: LockInfo? = null + ) : UiState + + data class LoadedForMultipleFiles(val actions: List, val fileCount: Int) : UiState + } + + private val _uiState: MutableLiveData = MutableLiveData(UiState.Loading) + val uiState: LiveData + get() = _uiState + + private val _clickActionId: MutableLiveData = MutableLiveData(null) + val clickActionId: LiveData + @IdRes + get() = _clickActionId + + fun load(arguments: Bundle, componentsGetter: ComponentsGetter) { + val files: List? = arguments.getParcelableArrayList(ARG_FILES) + val numberOfAllFiles: Int = arguments.getInt(ARG_ALL_FILES_COUNT, 1) + val isOverflow = arguments.getBoolean(ARG_IS_OVERFLOW, false) + val additionalFilter: IntArray? = arguments.getIntArray(ARG_ADDITIONAL_FILTER) + val inSingleFileFragment = arguments.getBoolean(ARG_IN_SINGLE_FILE_FRAGMENT) + + if (files.isNullOrEmpty()) { + logger.d(TAG, "No valid files argument for loading actions") + _uiState.postValue(UiState.Error) + } else { + load(componentsGetter, files.toList(), numberOfAllFiles, isOverflow, additionalFilter, inSingleFileFragment) + } + } + + private fun load( + componentsGetter: ComponentsGetter, + files: Collection, + numberOfAllFiles: Int?, + isOverflow: Boolean?, + additionalFilter: IntArray?, + inSingleFileFragment: Boolean = false + ) { + viewModelScope.launch(Dispatchers.IO) { + val toHide = getHiddenActions(componentsGetter, numberOfAllFiles, files, isOverflow, inSingleFileFragment) + val availableActions = getActionsToShow(additionalFilter, toHide, files) + updateStateLoaded(files, availableActions) + } + } + + private fun getHiddenActions( + componentsGetter: ComponentsGetter, + numberOfAllFiles: Int?, + files: Collection, + isOverflow: Boolean?, + inSingleFileFragment: Boolean + ): List = filterFactory.newInstance( + numberOfAllFiles ?: 1, + files.toList(), + componentsGetter, + isOverflow ?: false, + currentAccountProvider.user + ) + .getToHide(inSingleFileFragment) + + private fun getActionsToShow(additionalFilter: IntArray?, toHide: List, files: Collection) = + FileAction.getActions(files) + .filter { additionalFilter == null || it.id !in additionalFilter } + .filter { it.id !in toHide } + + private fun updateStateLoaded(files: Collection, availableActions: List) { + val state: UiState = when (files.size) { + 1 -> { + val file = files.first() + UiState.LoadedForSingleFile(availableActions, file, getLockInfo(file)) + } + + else -> UiState.LoadedForMultipleFiles(availableActions, files.size) + } + _uiState.postValue(state) + } + + private fun getLockInfo(file: OCFile): LockInfo? { + val lockType = file.lockType + val username = file.lockOwnerDisplayName ?: file.lockOwnerId + return if (file.isLocked && lockType != null && username != null) { + LockInfo(lockType, username, getLockedUntil(file)) + } else { + null + } + } + + private fun getLockedUntil(file: OCFile): Long? = if (file.lockTimestamp == 0L || file.lockTimeout == 0L) { + null + } else { + (file.lockTimestamp + file.lockTimeout) * TimeConstants.MILLIS_PER_SECOND + } + + fun onClick(action: FileAction) { + _clickActionId.value = action.id + } + + companion object { + const val ARG_ALL_FILES_COUNT = "ALL_FILES_COUNT" + const val ARG_FILES = "FILES" + const val ARG_IS_OVERFLOW = "OVERFLOW" + const val ARG_ADDITIONAL_FILTER = "ADDITIONAL_FILTER" + const val ARG_IN_SINGLE_FILE_FRAGMENT = "IN_SINGLE_FILE_FRAGMENT" + const val ARG_ENDPOINTS = "ENDPOINTS" + + private val TAG = FileActionsViewModel::class.simpleName!! + } +} diff --git a/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileAction.kt b/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileAction.kt new file mode 100644 index 000000000000..9054e8f4437e --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileAction.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.trashbinFileActions + +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import com.owncloud.android.R + +enum class TrashbinFileAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRes val icon: Int? = null) { + DELETE_PERMANENTLY(R.id.action_delete, R.string.trashbin_file_remove, R.drawable.ic_delete), + RESTORE(R.id.restore, R.string.restore_item, R.drawable.ic_history), + SELECT_ALL(R.id.action_select_all_action_menu, R.string.select_all, R.drawable.ic_select_all), + SELECT_NONE(R.id.action_deselect_all_action_menu, R.string.deselect_all, R.drawable.ic_select_none); + + companion object { + /** + * All file actions, in the order they should be displayed + */ + @JvmField + val SORTED_VALUES = listOf( + DELETE_PERMANENTLY, + RESTORE, + SELECT_ALL, + SELECT_NONE + ) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsBottomSheet.kt new file mode 100644 index 000000000000..89573ec5f443 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsBottomSheet.kt @@ -0,0 +1,238 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.trashbinFileActions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.IdRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.os.bundleOf +import androidx.core.view.isEmpty +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.di.ViewModelFactory +import com.nextcloud.utils.extensions.toOCFile +import com.owncloud.android.R +import com.owncloud.android.databinding.FileActionsBottomSheetBinding +import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.overlay.OverlayManager +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class TrashbinFileActionsBottomSheet : + BottomSheetDialogFragment(), + Injectable { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var vmFactory: ViewModelFactory + + @Inject + lateinit var currentUserProvider: CurrentAccountProvider + + @Inject + lateinit var storageManager: FileDataStorageManager + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var overlayManager: OverlayManager + + private lateinit var viewModel: TrashbinFileActionsViewModel + + private var _binding: FileActionsBottomSheetBinding? = null + val binding + get() = _binding!! + + private val thumbnailAsyncTasks = mutableListOf() + + fun interface ResultListener { + fun onResult(@IdRes actionId: Int) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + viewModel = ViewModelProvider(this, vmFactory)[TrashbinFileActionsViewModel::class.java] + _binding = FileActionsBottomSheetBinding.inflate(inflater, container, false) + + viewModel.uiState.observe(viewLifecycleOwner, this::handleState) + + viewModel.clickActionId.observe(viewLifecycleOwner) { id -> + dispatchActionClick(id) + } + + viewModel.load(requireArguments()) + + val bottomSheetDialog = dialog as BottomSheetDialog + bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + bottomSheetDialog.behavior.skipCollapsed = true + + viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE) + + return binding.root + } + + private fun handleState(state: TrashbinFileActionsViewModel.UiState) { + toggleLoadingOrContent(state) + when (state) { + is TrashbinFileActionsViewModel.UiState.LoadedForSingleFile -> { + loadFileThumbnail(state.titleFile) + displayActions(state.actions) + displayTitle(state.titleFile) + } + + is TrashbinFileActionsViewModel.UiState.LoadedForMultipleFiles -> { + setMultipleFilesThumbnail() + displayActions(state.actions) + displayTitle(state.fileCount) + } + + TrashbinFileActionsViewModel.UiState.Loading -> {} + + TrashbinFileActionsViewModel.UiState.Error -> { + activity?.let { + DisplayUtils.showSnackMessage(it, R.string.error_file_actions) + } + dismissAllowingStateLoss() + } + } + } + + private fun loadFileThumbnail(titleFile: TrashbinFile?) { + titleFile?.let { + DisplayUtils.setThumbnail( + it.toOCFile(), + binding.thumbnailLayout.thumbnail, + currentUserProvider.user, + storageManager, + thumbnailAsyncTasks, + false, + context, + binding.thumbnailLayout.thumbnailShimmer, + syncedFolderProvider.preferences, + viewThemeUtils, + overlayManager + ) + } + } + + private fun setMultipleFilesThumbnail() { + context?.let { + val drawable = viewThemeUtils.platform.tintDrawable(it, R.drawable.file_multiple, ColorRole.PRIMARY) + binding.thumbnailLayout.thumbnail.setImageDrawable(drawable) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + fun setResultListener( + fragmentManager: FragmentManager, + lifecycleOwner: LifecycleOwner, + listener: ResultListener + ): TrashbinFileActionsBottomSheet { + fragmentManager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner) { _, result -> + @IdRes val actionId = result.getInt(RESULT_KEY_ACTION_ID, -1) + if (actionId != -1) { + listener.onResult(actionId) + } + } + return this + } + + private fun toggleLoadingOrContent(state: TrashbinFileActionsViewModel.UiState) { + if (state is TrashbinFileActionsViewModel.UiState.Loading) { + binding.bottomSheetLoading.isVisible = true + binding.bottomSheetHeader.isVisible = false + viewThemeUtils.platform.colorCircularProgressBar(binding.bottomSheetLoading, ColorRole.PRIMARY) + } else { + binding.bottomSheetLoading.isVisible = false + binding.bottomSheetHeader.isVisible = true + } + } + + private fun displayActions(actions: List) { + if (binding.fileActionsList.isEmpty()) { + actions.forEach { action -> + val view = inflateActionView(action) + binding.fileActionsList.addView(view) + } + } + } + + private fun displayTitle(titleFile: TrashbinFile?) { + titleFile?.fileName?.let { + binding.title.text = it + } ?: { binding.title.isVisible = false } + } + + private fun displayTitle(fileCount: Int) { + binding.title.text = resources.getQuantityString(R.plurals.trashbin_list__footer__file, fileCount, fileCount) + } + + private fun inflateActionView(action: TrashbinFileAction): View { + val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false) + .apply { + root.setOnClickListener { + viewModel.onClick(action) + } + text.setText(action.title) + if (action.icon != null) { + val drawable = + viewThemeUtils.platform.tintDrawable( + requireContext(), + AppCompatResources.getDrawable(requireContext(), action.icon)!! + ) + icon.setImageDrawable(drawable) + } + } + return itemBinding.root + } + + private fun dispatchActionClick(id: Int?) { + if (id != null) { + setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_ACTION_ID to id)) + parentFragmentManager.clearFragmentResultListener(REQUEST_KEY) + dismiss() + } + } + + companion object { + private const val REQUEST_KEY = "REQUEST_KEY_ACTION" + private const val RESULT_KEY_ACTION_ID = "RESULT_KEY_ACTION_ID" + + @JvmStatic + fun newInstance(numberOfAllFiles: Int, files: Collection): TrashbinFileActionsBottomSheet = + TrashbinFileActionsBottomSheet().apply { + val argsBundle = bundleOf( + TrashbinFileActionsViewModel.ARG_ALL_FILES_COUNT to numberOfAllFiles, + TrashbinFileActionsViewModel.ARG_FILES to ArrayList(files) + ) + arguments = argsBundle + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsViewModel.kt b/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsViewModel.kt new file mode 100644 index 000000000000..155897873908 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsViewModel.kt @@ -0,0 +1,96 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.trashbinFileActions + +import android.os.Bundle +import androidx.annotation.IdRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.logger.Logger +import com.nextcloud.ui.fileactions.FileActionsViewModel +import com.owncloud.android.R +import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +class TrashbinFileActionsViewModel @Inject constructor(private val logger: Logger) : ViewModel() { + + sealed interface UiState { + data object Loading : UiState + data object Error : UiState + data class LoadedForSingleFile(val actions: List, val titleFile: TrashbinFile?) : UiState + + data class LoadedForMultipleFiles(val actions: List, val fileCount: Int) : UiState + } + + private val _uiState: MutableLiveData = MutableLiveData(UiState.Loading) + val uiState: LiveData + get() = _uiState + + private val _clickActionId: MutableLiveData = MutableLiveData(null) + val clickActionId: LiveData + @IdRes + get() = _clickActionId + + fun load(arguments: Bundle) { + val files: List? = arguments.getParcelableArrayList(ARG_FILES) + val numberOfAllFiles: Int = arguments.getInt(FileActionsViewModel.ARG_ALL_FILES_COUNT, 1) + + if (files.isNullOrEmpty()) { + logger.d(TAG, "No valid files argument for loading actions") + _uiState.postValue(UiState.Error) + } else { + load(files.toList(), numberOfAllFiles) + } + } + + private fun load(files: Collection, numberOfAllFiles: Int?) { + viewModelScope.launch(Dispatchers.IO) { + val toHide = getHiddenActions(numberOfAllFiles, files) + val availableActions = getActionsToShow(toHide) + updateStateLoaded(files, availableActions) + } + } + + private fun getHiddenActions(numberOfAllFiles: Int?, files: Collection): List { + numberOfAllFiles?.let { + if (files.size >= it) { + return listOf(R.id.action_select_all_action_menu) + } + } + + return listOf() + } + + private fun getActionsToShow(toHide: List) = TrashbinFileAction.SORTED_VALUES.filter { it.id !in toHide } + + private fun updateStateLoaded(files: Collection, availableActions: List) { + val state: UiState = when (files.size) { + 1 -> { + val file = files.first() + UiState.LoadedForSingleFile(availableActions, file) + } + + else -> UiState.LoadedForMultipleFiles(availableActions, files.size) + } + _uiState.postValue(state) + } + + fun onClick(action: TrashbinFileAction) { + _clickActionId.value = action.id + } + + companion object { + const val ARG_ALL_FILES_COUNT = "ALL_FILES_COUNT" + const val ARG_FILES = "FILES" + + private val TAG = TrashbinFileActionsViewModel::class.simpleName!! + } +} diff --git a/app/src/main/java/com/nextcloud/utils/BatteryOptimizationHelper.kt b/app/src/main/java/com/nextcloud/utils/BatteryOptimizationHelper.kt new file mode 100644 index 000000000000..eb4add0298c1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/BatteryOptimizationHelper.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import android.provider.Settings +import androidx.core.net.toUri +import com.owncloud.android.lib.common.utils.Log_OC + +object BatteryOptimizationHelper { + + private const val TAG = "BatteryOptimizationHelper" + + fun isBatteryOptimizationEnabled(context: Context): Boolean { + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return !pm.isIgnoringBatteryOptimizations(context.packageName) + } + + @Suppress("TooGenericExceptionCaught") + @SuppressLint("BatteryLife") + fun openBatteryOptimizationSettings(context: Context) { + try { + val intent = Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:${context.packageName}".toUri() + ) + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + // Fallback to generic battery optimization settings + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } + } catch (e: Exception) { + Log_OC.d(TAG, "open battery optimization settings: ", e) + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/BitmapExtensions.kt b/app/src/main/java/com/nextcloud/utils/BitmapExtensions.kt new file mode 100644 index 000000000000..104efca9972f --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/BitmapExtensions.kt @@ -0,0 +1,165 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import androidx.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import com.nextcloud.utils.extensions.toFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.BitmapUtils.calculateSampleFactor + +private const val TAG = "BitmapExtension" + +@Suppress("MagicNumber") +fun Bitmap.allocationKilobyte(): Int = allocationByteCount.div(1024) + +/** + * Recursively scales down the Bitmap until its size allocation is within the specified size. + * + * This function checks if the current Bitmap's size (in kilobytes) is already within + * the target size. If not, it scales the Bitmap down by a factor of `1.5` in both width and height + * and calls itself recursively until the size condition is met. + * + * @receiver Bitmap The original Bitmap to be resized. + * @param targetKB The target size in kilobytes (KB) that the Bitmap should be reduced to. + * @return A scaled-down Bitmap that meets the size allocation requirement. + */ +@Suppress("MagicNumber") +fun Bitmap.scaleUntil(targetKB: Int): Bitmap { + if (allocationKilobyte() <= targetKB) { + return this + } + + // 1.5 is used to gradually scale down while minimizing distortion + val scaleRatio = 1.5 + val width = width.div(scaleRatio).toInt() + val height = height.div(scaleRatio).toInt() + + val scaledBitmap = scale(width, height) + return scaledBitmap.scaleUntil(targetKB) +} + +/** + * Rotates and/or flips a [Bitmap] according to an EXIF orientation constant. + * + * Needed because loading bitmaps directly may ignore EXIF metadata with some devices, + * resulting in incorrectly displayed images. + * + * This function uses a [Matrix] transformation to adjust the image so that it + * appears upright when displayed. It supports all standard EXIF orientations, + * including mirrored and rotated cases. + * + * The original bitmap will be recycled if a new one is successfully created. + * If the device runs out of memory during the transformation, the original bitmap + * is returned unchanged. + * + * @receiver The [Bitmap] to rotate or flip. Can be `null`. + * @param orientation One of the [ExifInterface] orientation constants, such as + * [ExifInterface.ORIENTATION_ROTATE_90] or [ExifInterface.ORIENTATION_FLIP_HORIZONTAL]. + * @return The correctly oriented [Bitmap], or `null` if the receiver was `null`. + * + * @see ExifInterface + * @see Matrix + */ +@Suppress("MagicNumber", "ReturnCount") +fun Bitmap?.rotateBitmapViaExif(orientation: Int): Bitmap? { + if (this == null) { + return null + } + + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_NORMAL -> return this + + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) + + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) + + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.setRotate(180f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) + + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) + + else -> return this + } + + return try { + val rotated = Bitmap.createBitmap( + this, + 0, + 0, + this.width, + this.height, + matrix, + true + ) + + // release original if a new one was created + if (rotated != this) { + this.recycle() + } + + rotated + } catch (_: OutOfMemoryError) { + Log_OC.e("BitmapExtension", "rotating bitmap, out of memory exception") + this + } +} + +/** + * Decodes a bitmap from a file path while minimizing memory usage. + * + * This function first checks if the file exists (via [toFile]), then performs following steps: + * + * 1. Reads image dimensions using [BitmapFactory.Options.inJustDecodeBounds] without allocating memory. + * 2. Calculates a sampling factor with [calculateSampleFactor] to scale down large images efficiently. + * 3. Decodes the actual bitmap using the computed sample size. + * + * @param srcPath Absolute path to the image file. + * @param reqWidth Desired width in pixels of the output bitmap. + * @param reqHeight Desired height in pixels of the output bitmap. + * @return The decoded [Bitmap], or `null` if the file does not exist or decoding fails. + */ +@Suppress("TooGenericExceptionCaught") +fun decodeSampledBitmapFromFile(srcPath: String?, reqWidth: Int, reqHeight: Int): Bitmap? { + // check existence of file + srcPath?.toFile() ?: return null + + // Read image dimensions without allocating memory just to get pixels + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(srcPath, options) + + // Calculate sampling factor + options.inSampleSize = calculateSampleFactor(options, reqWidth, reqHeight) + options.inJustDecodeBounds = false + + // Decode actual bitmap + return try { + BitmapFactory.decodeFile(srcPath, options) + } catch (e: Exception) { + Log_OC.e(TAG, "exception during decoding path: $e") + null + } +} diff --git a/app/src/main/java/com/nextcloud/utils/BuildHelper.kt b/app/src/main/java/com/nextcloud/utils/BuildHelper.kt new file mode 100644 index 000000000000..2d17528e83c3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/BuildHelper.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils + +import com.owncloud.android.BuildConfig + +object BuildHelper { + fun isFlavourGPlay(): Boolean = BuildConfig.FLAVOR == "gplay" + + fun isHuaweiFlavor(): Boolean = BuildConfig.FLAVOR == "huawei" +} diff --git a/app/src/main/java/com/nextcloud/utils/CalendarEventManager.kt b/app/src/main/java/com/nextcloud/utils/CalendarEventManager.kt new file mode 100644 index 000000000000..a609071d2609 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/CalendarEventManager.kt @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.Manifest +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.CalendarContract +import com.nextcloud.utils.extensions.showToast +import com.owncloud.android.R +import com.owncloud.android.lib.common.SearchResultEntry +import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface +import com.owncloud.android.utils.PermissionUtil.checkSelfPermission + +class CalendarEventManager(private val context: Context) { + + fun openCalendarEvent(searchResult: SearchResultEntry, listInterface: UnifiedSearchListInterface) { + val havePermission = checkSelfPermission(context, Manifest.permission.READ_CALENDAR) + val createdAt = searchResult.createdAt() + val eventId: Long? = if (havePermission && createdAt != null) { + getCalendarEventId(searchResult.title, createdAt) + } else { + null + } + + if (eventId == null) { + val messageId = if (havePermission) { + R.string.unified_search_fragment_calendar_event_not_found + } else { + R.string.unified_search_fragment_permission_needed + } + context.showToast(messageId) + listInterface.onSearchResultClicked(searchResult) + } else { + val uri: Uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) + val intent = Intent(Intent.ACTION_VIEW).setData(uri) + context.startActivity(intent) + } + } + + private fun getCalendarEventId(eventTitle: String, eventStartDate: Long): Long? { + val projection = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART + ) + + val selection = "${CalendarContract.Events.TITLE} = ? AND ${CalendarContract.Events.DTSTART} = ?" + val selectionArgs = arrayOf(eventTitle, eventStartDate.toString()) + + val cursor = context.contentResolver.query( + CalendarContract.Events.CONTENT_URI, + projection, + selection, + selectionArgs, + "${CalendarContract.Events.DTSTART} ASC" + ) + + cursor?.use { + if (cursor.moveToFirst()) { + val idIndex = cursor.getColumnIndex(CalendarContract.Events._ID) + return cursor.getLong(idIndex) + } + } + + return null + } +} + +@Suppress("MagicNumber") +private fun SearchResultEntry.createdAt(): Long? = attributes["createdAt"]?.toLongOrNull()?.times(1000L) diff --git a/app/src/main/java/com/nextcloud/utils/ContactManager.kt b/app/src/main/java/com/nextcloud/utils/ContactManager.kt new file mode 100644 index 000000000000..c956e17f640e --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/ContactManager.kt @@ -0,0 +1,144 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.ContactsContract +import com.nextcloud.utils.extensions.showToast +import com.owncloud.android.R +import com.owncloud.android.lib.common.SearchResultEntry +import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface +import com.owncloud.android.utils.PermissionUtil.checkSelfPermission + +class ContactManager(private val context: Context) { + + fun openContact(searchResult: SearchResultEntry, listInterface: UnifiedSearchListInterface) { + val havePermission = checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + val displayName = searchResult.displayName() + val contactId: Long? = if (havePermission && displayName != null) { + getContactIds(displayName).let { contactIds -> + if (contactIds.size > 1) getContactId(searchResult, contactIds) else contactIds.firstOrNull() + } + } else { + null + } + + if (contactId == null) { + val messageId = if (havePermission) { + R.string.unified_search_fragment_contact_not_found + } else { + R.string.unified_search_fragment_permission_needed + } + context.showToast(messageId) + listInterface.onSearchResultClicked(searchResult) + } else { + val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, contactId.toString()) + val intent = Intent(Intent.ACTION_VIEW).apply { + setData(uri) + } + context.startActivity(intent) + } + } + + private fun getContactId(searchResult: SearchResultEntry, contactIds: List): Long? { + val email = searchResult.email() + val phoneNumber = searchResult.phoneNumber() + + contactIds.forEach { + val targetEmail = getEmailById(it) ?: "" + val targetPhoneNumber = getPhoneNumberById(it) ?: "" + if (targetEmail == email && targetPhoneNumber == phoneNumber) { + return it + } + } + + return null + } + + private fun getEmailById(contactId: Long): String? { + var result: String? = null + val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS) + val selection = "${ContactsContract.CommonDataKinds.Email.CONTACT_ID} = ?" + val selectionArgs = arrayOf(contactId.toString()) + + val cursor = context.contentResolver.query( + ContactsContract.CommonDataKinds.Email.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + cursor?.use { + val emailIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS) + while (cursor.moveToNext()) { + result = cursor.getString(emailIndex) + } + } + + return result + } + + private fun getPhoneNumberById(contactId: Long): String? { + var result: String? = null + val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER) + val selection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?" + val selectionArgs = arrayOf(contactId.toString()) + + val cursor = context.contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + cursor?.use { + val phoneIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + while (cursor.moveToNext()) { + result = cursor.getString(phoneIndex) + } + } + + return result + } + + private fun getContactIds(displayName: String): List { + val result = arrayListOf() + val projection = arrayOf(ContactsContract.Contacts._ID) + val selection = "${ContactsContract.Contacts.DISPLAY_NAME} = ?" + val selectionArgs = arrayOf(displayName) + + val cursor = context.contentResolver.query( + ContactsContract.Contacts.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + cursor?.use { + val idIndex = cursor.getColumnIndex(ContactsContract.Contacts._ID) + while (cursor.moveToNext()) { + val id = cursor.getLong(idIndex) + result.add(id) + } + } + + return result + } +} + +private fun SearchResultEntry.displayName(): String? = attributes["displayName"] + +private fun SearchResultEntry.email(): String? = attributes["email"] + +private fun SearchResultEntry.phoneNumber(): String? = attributes["phoneNumber"] diff --git a/app/src/main/java/com/nextcloud/utils/EditorUtils.kt b/app/src/main/java/com/nextcloud/utils/EditorUtils.kt new file mode 100644 index 000000000000..c45e14e84081 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/EditorUtils.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils + +import com.google.gson.Gson +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.lib.common.DirectEditing +import com.owncloud.android.lib.common.Editor +import javax.inject.Inject + +class EditorUtils @Inject constructor(private val arbitraryDataProvider: ArbitraryDataProvider) { + + fun getEditor(user: User?, mimeType: String?): Editor? { + val json = arbitraryDataProvider.getValue(user, ArbitraryDataProvider.DIRECT_EDITING) + if (json.isEmpty()) { + return null + } + val editors = Gson().fromJson(json, DirectEditing::class.java).editors.values + return editors.firstOrNull { mimeType in it.mimetypes } + ?: editors.firstOrNull { mimeType in it.optionalMimetypes } + } + + fun isEditorAvailable(user: User?, mimeType: String?): Boolean = getEditor(user, mimeType) != null +} diff --git a/app/src/main/java/com/nextcloud/utils/FileHelper.kt b/app/src/main/java/com/nextcloud/utils/FileHelper.kt new file mode 100644 index 000000000000..e417f88bb00e --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/FileHelper.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.owncloud.android.lib.common.utils.Log_OC +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.stream.Collectors +import kotlin.io.path.pathString + +@Suppress("NestedBlockDepth") +object FileHelper { + private const val TAG = "FileHelper" + + fun listDirectoryEntries(directory: File?, startIndex: Int, maxItems: Int, fetchFolders: Boolean): List { + if (directory == null || !directory.exists() || !directory.isDirectory) return emptyList() + + return try { + Files.list(directory.toPath()) + .map { it.toFile() } + .filter { file -> if (fetchFolders) file.isDirectory else !file.isDirectory } + .skip(startIndex.toLong()) + .limit(maxItems.toLong()) + .collect(Collectors.toList()) + } catch (e: IOException) { + Log_OC.d(TAG, "listDirectoryEntries: $e") + emptyList() + } + } + + fun listFilesRecursive(files: Collection): List { + val result = mutableListOf() + + for (file in files) { + try { + collectFilesRecursively(file.toPath(), result) + } catch (e: IOException) { + Log_OC.e(TAG, "Error collecting files recursively from: ${file.absolutePath}", e) + } + } + + return result + } + + private fun collectFilesRecursively(path: Path, result: MutableList) { + if (Files.isDirectory(path)) { + try { + Files.newDirectoryStream(path).use { stream -> + for (entry in stream) { + collectFilesRecursively(entry, result) + } + } + } catch (e: IOException) { + Log_OC.e(TAG, "Error reading directory: ${path.pathString}", e) + } + } else { + result.add(path.pathString) + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/ForegroundServiceHelper.kt b/app/src/main/java/com/nextcloud/utils/ForegroundServiceHelper.kt new file mode 100644 index 000000000000..c46c314845bd --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/ForegroundServiceHelper.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils + +import android.app.Notification +import android.app.Service +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import androidx.core.app.ServiceCompat +import androidx.work.ForegroundInfo +import com.owncloud.android.datamodel.ForegroundServiceType + +object ForegroundServiceHelper { + private const val TAG = "ForegroundServiceHelper" + private val isAboveOrEqualAndroid10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + @Suppress("TooGenericExceptionCaught") + fun startService( + service: Service, + id: Int, + notification: Notification, + foregroundServiceType: ForegroundServiceType + ) { + if (isAboveOrEqualAndroid10) { + try { + ServiceCompat.startForeground( + service, + id, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } catch (e: Exception) { + Log.d(TAG, "Exception caught at ForegroundServiceHelper.startService: $e") + } + } else { + service.startForeground(id, notification) + } + } + + fun createWorkerForegroundInfo( + id: Int, + notification: Notification, + foregroundServiceType: ForegroundServiceType + ): ForegroundInfo = if (isAboveOrEqualAndroid10) { + ForegroundInfo(id, notification, foregroundServiceType.getId()) + } else { + ForegroundInfo(id, notification) + } +} diff --git a/app/src/main/java/com/nextcloud/utils/GlideHelper.kt b/app/src/main/java/com/nextcloud/utils/GlideHelper.kt new file mode 100644 index 000000000000..36847652f071 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/GlideHelper.kt @@ -0,0 +1,247 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.graphics.drawable.PictureDrawable +import android.widget.ImageView +import androidx.activity.ComponentActivity +import androidx.annotation.DrawableRes +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.LazyHeaders +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.BitmapImageViewTarget +import com.bumptech.glide.request.target.Target +import com.nextcloud.common.NextcloudClient +import com.nextcloud.utils.LinkHelper.validateAndGetURL +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.svg.SvgSoftwareLayerSetter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Utility object for loading images (including SVGs) using Glide. + * + * Provides methods for loading images into `ImageView`, `Target`, `Target` ... + * from both URLs and URIs. + */ +@Suppress("TooManyFunctions", "TooGenericExceptionCaught") +object GlideHelper { + private const val TAG = "GlideHelper" + + @Suppress("TooGenericExceptionCaught") + fun getBitmap(context: Context, url: String?): Bitmap? { + val validatedUrl = validateAndGetURL(url) ?: return null + + return try { + Glide.with(context) + .asBitmap() + .load(validatedUrl) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .withLogging("downloadImageSynchronous", validatedUrl) + .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .get() + } catch (e: Exception) { + Log_OC.e(TAG, "exception getBitmap: $e") + null + } + } + + fun loadCircularBitmapIntoImageView(context: Context, url: String?, imageView: ImageView, placeholder: Drawable?) { + val validatedUrl = validateAndGetURL(url) ?: return + + try { + Glide.with(context) + .asBitmap() + .load(validatedUrl) + .placeholder(placeholder) + .error(placeholder) + .withLogging("loadCircularBitmapIntoImageView", validatedUrl) + .into(object : BitmapImageViewTarget(imageView) { + override fun setResource(resource: Bitmap?) { + val circularBitmapDrawable = RoundedBitmapDrawableFactory.create(context.resources, resource) + circularBitmapDrawable.isCircular = true + imageView.setImageDrawable(circularBitmapDrawable) + } + }) + } catch (e: Exception) { + Log_OC.e(TAG, "exception loadCircularBitmapIntoImageView: $e") + imageView.setImageDrawable(placeholder) + } + } + + @SuppressLint("CheckResult") + fun loadIntoImageView( + context: Context, + client: NextcloudClient?, + url: String?, + imageView: ImageView, + @DrawableRes placeholder: Int, + circleCrop: Boolean = false + ) { + try { + createRequestBuilder(context, client, url) + ?.placeholder(placeholder) + ?.error(placeholder) + ?.apply { if (circleCrop) circleCrop() } + ?.withLogging("loadIntoImageView", url ?: "null") + ?.into(imageView) ?: imageView.setImageResource(placeholder) + } catch (e: Exception) { + Log_OC.e(TAG, "exception loadIntoImageView: $e") + imageView.setImageResource(placeholder) + } + } + + fun getDrawable(context: Context, client: NextcloudClient?, urlString: String?): Drawable? = try { + createRequestBuilder(context, client, urlString)?.submit()?.get() + } catch (e: Exception) { + Log_OC.e(TAG, "exception getDrawable: $e") + null + } + + fun loadIntoTarget( + activity: ComponentActivity, + account: OwnCloudAccount?, + url: String, + target: Target, + @DrawableRes placeholder: Int + ) { + if (account == null) { + Log_OC.e(TAG, "loadIntoTargetWithActivity: account cannot be null") + return + } + + activity.lifecycleScope.launch(Dispatchers.IO) { + val clientFactory = OwnCloudClientManagerFactory.getDefaultSingleton() + val client = clientFactory.getNextcloudClientFor(account, activity) + withContext(Dispatchers.Main) { + try { + createRequestBuilder(activity, client, url) + ?.placeholder(placeholder) + ?.error(placeholder) + ?.withLogging("loadIntoTarget", url) + ?.into(target) + } catch (e: Exception) { + Log_OC.e(TAG, "exception loadIntoTarget: $e") + } + } + } + } + + fun createGlideUrl(url: String, client: NextcloudClient) = GlideUrl( + url, + LazyHeaders.Builder() + .addHeader("Authorization", client.credentials) + .addHeader("User-Agent", "Mozilla/5.0 (Android) Nextcloud-android") + .build() + ) + + // region private methods + private class GlideLogger(private val methodName: String, private val identifier: String) : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + Log_OC.e(TAG, "$methodName: Load failed for $identifier") + Log_OC.e(TAG, "$methodName: Error: ${e?.message}") + e?.logRootCauses(TAG) + return false + } + + override fun onResourceReady( + resource: T & Any, + model: Any?, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + Log_OC.i(TAG, "$methodName: Successfully loaded $identifier from $dataSource") + return false + } + } + + private fun isSVG(url: String): Boolean = (url.toUri().encodedPath?.endsWith(".svg") == true) + + private fun RequestBuilder.withLogging(methodName: String, identifier: String): RequestBuilder = + listener(GlideLogger(methodName, identifier)) + + @SuppressLint("CheckResult") + private fun createSvgRequestBuilder( + context: Context, + uri: String, + client: NextcloudClient, + placeholder: Int? = null + ): RequestBuilder { + val glideUrl = createGlideUrl(uri, client) + + return Glide.with(context) + .`as`(PictureDrawable::class.java) + .load(glideUrl) + .apply { + placeholder?.let { + placeholder(it) + error(it) + } + } + .listener(SvgSoftwareLayerSetter()) + } + + private fun createUrlRequestBuilder( + context: Context, + client: NextcloudClient, + url: String + ): RequestBuilder { + val glideUrl = createGlideUrl(url, client) + return Glide.with(context) + .asDrawable() + .load(glideUrl) + .centerCrop() + } + + @Suppress("UNCHECKED_CAST", "TooGenericExceptionCaught", "ReturnCount") + private fun createRequestBuilder(context: Context, client: NextcloudClient?, url: String?): RequestBuilder? { + if (client == null) { + Log_OC.e(TAG, "Client is null") + return null + } + + val validatedUrl = validateAndGetURL(url) ?: return null + + return try { + val isSVG = isSVG(validatedUrl) + + if (isSVG) { + createSvgRequestBuilder(context, validatedUrl, client) + } else { + createUrlRequestBuilder(context, client, validatedUrl) + }.withLogging("createRequestBuilder", validatedUrl) as RequestBuilder? + } catch (e: Exception) { + Log_OC.e(TAG, "exception createRequestBuilder: $e") + null + } + } + // endregion +} diff --git a/app/src/main/java/com/nextcloud/utils/LinkHelper.kt b/app/src/main/java/com/nextcloud/utils/LinkHelper.kt new file mode 100644 index 000000000000..75ee63098667 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/LinkHelper.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 ZetaTom <70907959+ZetaTom@users.noreply.github.com> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.owncloud.android.lib.common.utils.Log_OC +import java.util.Locale + +object LinkHelper { + private const val TAG = "LinkHelper" + + fun isHttpOrHttpsLink(link: String?): Boolean = link?.lowercase(Locale.getDefault())?.let { + it.startsWith("http://") || it.startsWith("https://") + } == true + + /** + * Open app store page of specified app or search for specified string. Will attempt to open browser when no app + * store is available. + * + * @param string packageName or url-encoded search string + * @param search false -> show app corresponding to packageName; true -> open search for string + */ + fun openAppStore(string: String, search: Boolean = false, context: Context) { + var suffix = (if (search) "search?q=" else "details?id=") + string + val intent = Intent(Intent.ACTION_VIEW, "market://$suffix".toUri()) + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + // all is lost: open google play store web page for app + if (!search) { + suffix = "apps/$suffix" + } + intent.setData("https://play.google.com/store/$suffix".toUri()) + context.startActivity(intent) + } + } + + // region Validation + private const val HTTP = "http" + private const val HTTPS = "https" + + /** + * Validates if a URL string is valid + */ + @Suppress("TooGenericExceptionCaught", "ReturnCount") + fun validateAndGetURL(url: String?): String? { + if (url.isNullOrBlank()) { + Log_OC.w(TAG, "Given url is null or blank") + return null + } + + return try { + val uri = url.toUri() + if (uri.scheme == null) { + return null + } + val validSchemes = listOf(HTTP, HTTPS) + if (uri.scheme in validSchemes) url else null + } catch (e: Exception) { + Log_OC.e(TAG, "Invalid URL: $url -- $e") + null + } + } + // endregion +} diff --git a/app/src/main/java/com/nextcloud/utils/MenuUtils.kt b/app/src/main/java/com/nextcloud/utils/MenuUtils.kt new file mode 100644 index 000000000000..56d150c6b1d0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/MenuUtils.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils + +import android.view.Menu +import android.view.MenuItem +import androidx.core.view.children + +object MenuUtils { + + @JvmStatic + fun showMenuItem(item: MenuItem?) { + item?.apply { + isVisible = true + isEnabled = true + } + } + + @JvmStatic + fun hideMenuItem(item: MenuItem?) { + item?.apply { + isVisible = false + isEnabled = false + } + } + + @JvmStatic + fun hideAll(menu: Menu?) { + menu?.children?.forEach(::hideMenuItem) + } + + @JvmStatic + fun hideMenuItems(vararg items: MenuItem?) { + items.filterNotNull().forEach(::hideMenuItem) + } +} diff --git a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt new file mode 100644 index 000000000000..d16ea8eef159 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt @@ -0,0 +1,82 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils + +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import com.nextcloud.utils.extensions.getBitmapSize +import com.nextcloud.utils.extensions.getExifSize +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.MimeTypeUtil + +@Suppress("TooGenericExceptionCaught", "ReturnCount") +object OCFileUtils { + private const val TAG = "OCFileUtils" + + fun getImageSize(ocFile: OCFile, defaultThumbnailSize: Float): Pair { + val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1) + val fallbackPair = fallback to fallback + + try { + Log_OC.d(TAG, "Getting image size for: ${ocFile.fileName}") + + // Server-provided + ocFile.imageDimension?.let { dim -> + val w = dim.width.toInt().coerceAtLeast(1) + val h = dim.height.toInt().coerceAtLeast(1) + Log_OC.d(TAG, "Using server-provided imageDimension: $w x $h") + return w to h + } + + // Local file + val path = ocFile.storagePath + if (!path.isNullOrEmpty() && ocFile.exists()) { + path.getExifSize()?.let { return it } + path.getBitmapSize()?.let { return it } + } + + // 3 Fallback + Log_OC.d(TAG, "Fallback to default size: $fallback x $fallback") + return fallbackPair + } catch (e: Exception) { + Log_OC.e(TAG, "Error getting image size for ${ocFile.fileName}", e) + } + + return fallbackPair + } + + fun getMediaPlaceholder(file: OCFile, imageDimension: Pair): BitmapDrawable { + val context = MainApp.getAppContext() + + val drawableId = if (MimeTypeUtil.isImage(file)) { + R.drawable.file_image + } else if (MimeTypeUtil.isVideo(file)) { + R.drawable.file_movie + } else { + R.drawable.file + } + + val drawable = ContextCompat.getDrawable(context, drawableId) + ?: return Color.GRAY.toDrawable().toBitmap(imageDimension.first, imageDimension.second) + .toDrawable(context.resources) + + val bitmap = BitmapUtils.drawableToBitmap( + drawable, + imageDimension.first, + imageDimension.second + ) + + return bitmap.toDrawable(context.resources) + } +} diff --git a/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt b/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt new file mode 100644 index 000000000000..f614e50aa4ba --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt @@ -0,0 +1,124 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Felix Nüsse + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils + +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import com.nextcloud.client.account.User +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderObserver +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class ShortcutUtil @Inject constructor(private val mContext: Context) { + + /** + * Adds a pinned shortcut to the home screen that points to the passed file/folder. + * + * @param file The file/folder to which a pinned shortcut should be added to the home screen. + */ + fun addShortcutToHomescreen( + file: OCFile, + viewThemeUtils: ViewThemeUtils, + user: User, + syncedFolderProvider: SyncedFolderProvider + ) { + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(mContext)) { + return + } + + val intent = Intent(mContext, FileDisplayActivity::class.java).apply { + action = FileDisplayActivity.OPEN_FILE + putExtra(FileActivity.EXTRA_FILE_REMOTE_PATH, file.decryptedRemotePath) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + val icon = createShortcutIcon(file, viewThemeUtils, user, syncedFolderProvider) + + val shortcutInfo = ShortcutInfoCompat.Builder(mContext, "nextcloud_shortcut_${file.remoteId}") + .setShortLabel(file.fileName) + .setLongLabel(mContext.getString(R.string.pin_shortcut_label, file.fileName)) + .setIcon(icon) + .setIntent(intent) + .build() + + val resultIntent = + ShortcutManagerCompat.createShortcutResultIntent(mContext, shortcutInfo) + + val pendingIntent = PendingIntent.getBroadcast( + mContext, + file.hashCode(), + resultIntent, + FLAG_IMMUTABLE + ) + + ShortcutManagerCompat.requestPinShortcut(mContext, shortcutInfo, pendingIntent.intentSender) + } + + private fun createShortcutIcon( + file: OCFile, + viewThemeUtils: ViewThemeUtils, + user: User, + syncedFolderProvider: SyncedFolderProvider + ): IconCompat { + val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId + ) + + return when { + thumbnail != null -> IconCompat.createWithAdaptiveBitmap(bitmapToAdaptiveBitmap(thumbnail)) + + file.isFolder -> { + val isAutoUploadFolder = SyncedFolderObserver.isAutoUploadFolder(file, user) + val isDarkModeActive = syncedFolderProvider.preferences.isDarkModeEnabled + val overlayIconId = file.getFileOverlayIconId(isAutoUploadFolder) + val drawable = MimeTypeUtil.getFolderIcon(isDarkModeActive, overlayIconId, mContext, viewThemeUtils) + IconCompat.createWithBitmap(drawable.toBitmap()) + } + + else -> IconCompat.createWithResource( + mContext, + MimeTypeUtil.getFileTypeIconId(file.mimeType, file.fileName) + ) + } + } + + private fun bitmapToAdaptiveBitmap(orig: Bitmap): Bitmap { + val adaptiveIconSize = mContext.resources.getDimensionPixelSize(R.dimen.adaptive_icon_size) + val adaptiveIconOuterSides = mContext.resources.getDimensionPixelSize(R.dimen.adaptive_icon_padding) + val drawable: Drawable = orig.toDrawable(mContext.resources) + val bitmap = createBitmap(adaptiveIconSize, adaptiveIconSize) + val canvas = Canvas(bitmap) + drawable.setBounds( + adaptiveIconOuterSides, + adaptiveIconOuterSides, + adaptiveIconSize - adaptiveIconOuterSides, + adaptiveIconSize - adaptiveIconOuterSides + ) + drawable.draw(canvas) + return bitmap + } +} diff --git a/app/src/main/java/com/nextcloud/utils/TimeConstants.kt b/app/src/main/java/com/nextcloud/utils/TimeConstants.kt new file mode 100644 index 000000000000..7c5797649190 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/TimeConstants.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils + +object TimeConstants { + const val MILLIS_PER_SECOND = 1000 +} diff --git a/app/src/main/java/com/nextcloud/utils/autoRename/AutoRename.kt b/app/src/main/java/com/nextcloud/utils/autoRename/AutoRename.kt new file mode 100644 index 000000000000..11dba9a9190b --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/autoRename/AutoRename.kt @@ -0,0 +1,135 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.autoRename + +import com.nextcloud.utils.extensions.StringConstants +import com.nextcloud.utils.extensions.checkWCFRestrictions +import com.nextcloud.utils.extensions.forbiddenFilenameCharacters +import com.nextcloud.utils.extensions.forbiddenFilenameExtensions +import com.nextcloud.utils.extensions.shouldRemoveNonPrintableUnicodeCharactersAndConvertToUTF8 +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.OCCapability +import org.apache.commons.io.FilenameUtils +import java.util.regex.Pattern + +object AutoRename { + private const val TAG = "AutoRename" + private const val REPLACEMENT = "_" + + @Suppress("NestedBlockDepth") + @JvmOverloads + fun rename(filename: String, capability: OCCapability, isFolderPath: Boolean? = null): String { + if (!capability.checkWCFRestrictions()) { + return filename + } + + Log_OC.d(TAG, "Before - $filename") + + val isFolder = isFolderPath ?: filename.endsWith(OCFile.PATH_SEPARATOR) + val pathSegments = filename.split(OCFile.PATH_SEPARATOR).toMutableList() + + capability.run { + if (forbiddenFilenameCharactersJson != null) { + var forbiddenFilenameCharacters = capability.forbiddenFilenameCharacters() + + if (isFolder) { + forbiddenFilenameCharacters = forbiddenFilenameCharacters.filter { it != OCFile.PATH_SEPARATOR } + } + + pathSegments.replaceAll { segment -> + var modifiedSegment = segment + + forbiddenFilenameCharacters.forEach { forbiddenChar -> + if (modifiedSegment.contains(forbiddenChar)) { + modifiedSegment = modifiedSegment.replace(forbiddenChar, REPLACEMENT) + } + } + + modifiedSegment + } + } + + if (forbiddenFilenameExtensionJson != null) { + val forbiddenFilenameExtensions = forbiddenFilenameExtensions() + + forbiddenFilenameExtensions.find { it == StringConstants.SPACE }?.let { + pathSegments.replaceAll { segment -> + segment.trim() + } + } + + forbiddenFilenameExtensions.find { it == StringConstants.DOT }?.let { forbiddenExtension -> + pathSegments.replaceAll { segment -> + replaceDots(forbiddenExtension, segment) + } + } + + forbiddenFilenameExtensions + .filter { it != StringConstants.SPACE && it != StringConstants.DOT } + .forEach { forbiddenExtension -> + pathSegments.replaceAll { segment -> + replaceFileExtensions(forbiddenExtension, segment) + } + } + } + } + + val filenameWithExtension = pathSegments.joinToString(OCFile.PATH_SEPARATOR) + val updatedFileName = if (isFolder) filenameWithExtension else lowercaseFileExtension(filenameWithExtension) + + val result = if (capability.shouldRemoveNonPrintableUnicodeCharactersAndConvertToUTF8()) { + val utf8Result = convertToUTF8(updatedFileName) + removeNonPrintableUnicodeCharacters(utf8Result) + } else { + updatedFileName + }.trim() + + Log_OC.d(TAG, "After - $result") + + return result + } + + private fun lowercaseFileExtension(filename: String): String { + val extension = FilenameUtils.getExtension(filename).lowercase() + val filenameWithoutExtension = FilenameUtils.removeExtension(filename) + return if (extension.isNotEmpty()) { + filenameWithoutExtension + StringConstants.DOT + extension + } else { + filenameWithoutExtension + } + } + + private fun replaceDots(forbiddenExtension: String, segment: String): String = + if (isSegmentContainsForbiddenExtension(forbiddenExtension, segment)) { + segment.replaceFirst(forbiddenExtension, REPLACEMENT) + } else { + segment + } + + private fun replaceFileExtensions(forbiddenExtension: String, segment: String): String = + if (isSegmentContainsForbiddenExtension(forbiddenExtension, segment)) { + val newExtension = forbiddenExtension.replace(StringConstants.DOT, REPLACEMENT, ignoreCase = true) + segment.replace(forbiddenExtension, newExtension.lowercase(), ignoreCase = true) + } else { + segment + } + + private fun isSegmentContainsForbiddenExtension(forbiddenExtension: String, segment: String): Boolean = + segment.endsWith(forbiddenExtension, ignoreCase = true) || + segment.startsWith(forbiddenExtension, ignoreCase = true) + + private fun convertToUTF8(filename: String): String = String(filename.toByteArray(), Charsets.UTF_8) + + private fun removeNonPrintableUnicodeCharacters(filename: String): String { + val regex = "\\p{C}" + val pattern = Pattern.compile(regex) + val matcher = pattern.matcher(filename) + return matcher.replaceAll("") + } +} diff --git a/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt b/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt new file mode 100644 index 000000000000..7324596e3a59 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.date + +enum class DateFormatPattern(val pattern: String) { + /** + * 10.11.2024 - 12:44 + */ + FullDateWithHours("dd.MM.yyyy - HH:mm"), + + /** + * Aug 3 + */ + MonthWithDate("MMM d"), + + /** + * March 03, 2026 14:38 + */ + MonthDayYearTime("MMMM dd, yyyy HH:mm") +} diff --git a/app/src/main/java/com/nextcloud/utils/date/DateFormatter.kt b/app/src/main/java/com/nextcloud/utils/date/DateFormatter.kt new file mode 100644 index 000000000000..ea43a5a27bc0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/date/DateFormatter.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.date + +import android.icu.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object DateFormatter { + + /** + * Converts a Unix timestamp (in milliseconds) into a formatted date string. + * For example, input 1733309160885 with "MMM d" pattern outputs "Dec 4". + */ + @Suppress("MagicNumber") + fun timestampToDateRepresentation(timestamp: Long, formatPattern: DateFormatPattern): String { + val date = Date(timestamp * 1000) + val format = SimpleDateFormat(formatPattern.pattern, Locale.getDefault()) + return format.format(date) + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EVersionHelper.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EVersionHelper.kt new file mode 100644 index 000000000000..90cd2a791c07 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EVersionHelper.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.google.gson.reflect.TypeToken +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFolderMetadataFile +import com.owncloud.android.lib.resources.status.E2EVersion +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.utils.EncryptionUtils + +object E2EVersionHelper { + + /** + * Returns true if the given E2EE version is v2 or newer. + */ + fun isV2Plus(capability: OCCapability): Boolean = isV2Plus(capability.endToEndEncryptionApiVersion) + + /** + * Returns true if the given E2EE version is v2 or newer. + */ + fun isV2Plus(version: E2EVersion): Boolean = version == E2EVersion.V2_0 || version == E2EVersion.V2_1 + + /** + * Returns true if the given E2EE version is v1.x. + */ + fun isV1(capability: OCCapability): Boolean = isV1(capability.endToEndEncryptionApiVersion) + + /** + * Returns true if the given E2EE version is v1.x. + */ + fun isV1(version: E2EVersion): Boolean = + version == E2EVersion.V1_0 || version == E2EVersion.V1_1 || version == E2EVersion.V1_2 + + /** + * Returns the latest supported E2EE version. + * + * @param isV2 indicates whether the E2EE v2 series should be used + */ + fun latestVersion(isV2: Boolean): E2EVersion = if (isV2) { + E2EVersion.V2_1 + } else { + E2EVersion.V1_2 + } + + /** + * Maps a raw version string to an [E2EVersion]. + * + * @param version version string + * @return resolved [E2EVersion] or [E2EVersion.UNKNOWN] if unsupported + */ + fun fromVersionString(version: String?): E2EVersion = when (version?.trim()) { + "1.0" -> E2EVersion.V1_0 + "1.1" -> E2EVersion.V1_1 + "1.2" -> E2EVersion.V1_2 + "2", "2.0" -> E2EVersion.V2_0 + "2.1" -> E2EVersion.V2_1 + else -> E2EVersion.UNKNOWN + } + + /** + * Determines the E2EE version by inspecting encrypted folder metadata. + * + * Supports both V1 and V2 metadata formats and falls back safely + * to [E2EVersion.UNKNOWN] if parsing fails. + */ + fun fromMetadata(metadata: String): E2EVersion = runCatching { + val v1 = EncryptionUtils.deserializeJSON( + metadata, + object : TypeToken() {} + ) + + fromVersionString(v1?.metadata?.version.toString()).also { + if (it == E2EVersion.UNKNOWN) { + throw IllegalStateException("Unknown V1 version") + } + } + }.recoverCatching { + val v2 = EncryptionUtils.deserializeJSON( + metadata, + object : TypeToken() {} + ) + + fromVersionString(v2.version) + }.getOrDefault(E2EVersion.UNKNOWN) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/AccountExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/AccountExtensions.kt new file mode 100644 index 000000000000..2f70ada3c9f7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/AccountExtensions.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.accounts.Account +import android.content.Context +import com.owncloud.android.R + +fun Account.isAnonymous(context: Context): Boolean = type.equals(context.getString(R.string.anonymous_account_type)) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ActionBarExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ActionBarExtensions.kt new file mode 100644 index 000000000000..3bafc9b850e9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ActionBarExtensions.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import androidx.appcompat.app.ActionBar + +fun ActionBar.setTitleColor(color: Int) { + val text = SpannableString(title ?: "") + text.setSpan(ForegroundColorSpan(color), 0, text.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE) + title = text +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt new file mode 100644 index 000000000000..c02e4e9f95f7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.app.Activity +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.nextcloud.model.WorkerState +import com.nextcloud.model.WorkerStateObserver +import kotlinx.coroutines.launch + +fun AppCompatActivity.isDialogFragmentReady(fragment: Fragment): Boolean = isActive() && !fragment.isStateSaved + +fun AppCompatActivity.isActive(): Boolean = !isFinishing && !isDestroyed + +fun AppCompatActivity.fragments(): List = supportFragmentManager.fragments + +fun AppCompatActivity.lastFragment(): Fragment? = supportFragmentManager.fragments.lastOrNull { it.isVisible } + +fun Activity.showShareIntent(text: String?) { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null) + startActivity(shareIntent) +} + +fun ComponentActivity.observeWorker(onCollect: (WorkerState?) -> Unit) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + WorkerStateObserver.events.collect { + onCollect(it) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/BundleExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/BundleExtensions.kt new file mode 100644 index 000000000000..d6ddbf4c2978 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/BundleExtensions.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.extensions + +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.BundleCompat +import java.io.Serializable + +fun Bundle?.getSerializableArgument(key: String, type: Class): T? { + if (this == null) { + return null + } + + return BundleCompat.getSerializable(this, key, type) +} + +fun Bundle?.getParcelableArgument(key: String, type: Class): T? { + if (this == null) { + return null + } + + return BundleCompat.getParcelable(this, key, type) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ChatMessageExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ChatMessageExtensions.kt new file mode 100644 index 000000000000..fae3a9d6f7a0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ChatMessageExtensions.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.time.ExperimentalTime + +fun ChatMessage.isHuman(): Boolean = (role == "human") + +@OptIn(ExperimentalTime::class) +fun ChatMessage.time(): String { + val messageDate = Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()).toLocalDate() + val today = LocalDate.now(ZoneId.systemDefault()) + + val pattern = if (messageDate == today) "HH:mm" else "dd.MM.yyyy - HH:mm" + + val formatter = DateTimeFormatter.ofPattern(pattern, Locale.getDefault()) + val messageTime = Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime() + + return formatter.format(messageTime) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ContextExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ContextExtensions.kt new file mode 100644 index 000000000000..53b3c35495b6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ContextExtensions.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.extensions + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.WindowInsets +import android.view.WindowManager +import android.widget.Toast +import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.owncloud.android.R +import com.owncloud.android.datamodel.ReceiverFlag + +fun Context.hourPlural(hour: Int): String = resources.getQuantityString(R.plurals.hours, hour, hour) + +fun Context.minPlural(min: Int): String = resources.getQuantityString(R.plurals.minutes, min, min) + +@SuppressLint("UnspecifiedRegisterReceiverFlag") +fun Context.registerBroadcastReceiver(receiver: BroadcastReceiver?, filter: IntentFilter, flag: ReceiverFlag): Intent? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, filter, flag.getId()) + } else { + registerReceiver(receiver, filter) + } + +fun Context.statusBarHeight(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowInsets = (getSystemService(Context.WINDOW_SERVICE) as WindowManager) + .currentWindowMetrics + .windowInsets + val insets = windowInsets.getInsets(WindowInsets.Type.statusBars()) + insets.top +} else { + @Suppress("DEPRECATION") + val decorView = (getSystemService(Context.WINDOW_SERVICE) as WindowManager) + .defaultDisplay + .let { display -> + val decorView = android.view.View(this) + display.getRealMetrics(android.util.DisplayMetrics()) + decorView + } + val windowInsetsCompat = ViewCompat.getRootWindowInsets(decorView) + windowInsetsCompat?.getInsets(WindowInsetsCompat.Type.statusBars())?.top ?: 0 +} + +fun Context.showToast(message: String) { + Handler(Looper.getMainLooper()).post { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } +} + +fun Context.showToast(messageId: Int) = showToast(getString(messageId)) + +fun Context.getActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null +} + +fun Activity.openMediaPermissions(requestCode: Int) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + startActivityForResult(intent, requestCode) +} + +fun Activity.openAllFilesAccessSettings(requestCode: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return + } + + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = "package:$packageName".toUri() + } + + startActivityForResult(intent, requestCode) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/DateExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/DateExtensions.kt new file mode 100644 index 000000000000..cda053240d36 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/DateExtensions.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.annotation.SuppressLint +import com.nextcloud.utils.date.DateFormatPattern +import java.text.SimpleDateFormat +import java.util.Date + +@SuppressLint("SimpleDateFormat") +fun Date.currentDateRepresentation(formatPattern: DateFormatPattern): String = + SimpleDateFormat(formatPattern.pattern).format(this) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/DecryptedUserExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/DecryptedUserExtensions.kt new file mode 100644 index 000000000000..ba4830681d44 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/DecryptedUserExtensions.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedUser + +fun List.findMetadataKeyByUserId(userId: String): String? { + var result: String? = null + + for (decryptedUser in this) { + if (decryptedUser != null && decryptedUser.userId == userId) { + result = decryptedUser.decryptedMetadataKey + } + } + + return result +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/DrawableExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/DrawableExtensions.kt new file mode 100644 index 000000000000..3fcefbb37c9a --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/DrawableExtensions.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.PictureDrawable +import androidx.core.graphics.createBitmap + +fun PictureDrawable.toBitmap(): Bitmap { + val bitmap = createBitmap(picture.getWidth(), picture.getHeight()) + val canvas = Canvas(bitmap) + picture.draw(canvas) + return bitmap +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/DrawerActivityExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/DrawerActivityExtensions.kt new file mode 100644 index 000000000000..95acef0970ef --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/DrawerActivityExtensions.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.content.Intent +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.ui.activity.DrawerActivity +import com.owncloud.android.ui.activity.FileDisplayActivity + +@JvmOverloads +fun DrawerActivity.navigateToAllFiles(onlyPersonal: Boolean = false) { + MainApp.showOnlyFilesOnDevice(false) + MainApp.showOnlyPersonalFiles(onlyPersonal) + highlightNavigationViewItem(R.id.nav_all_files) + setupHomeSearchToolbarWithSortAndListButtons() + + Intent(applicationContext, FileDisplayActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + action = FileDisplayActivity.ALL_FILES + }.run { + startActivity(this) + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/Extensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/Extensions.kt new file mode 100644 index 000000000000..598bff9e0fc8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/Extensions.kt @@ -0,0 +1,101 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.extensions + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.text.Selection +import android.text.Spannable +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.View +import android.widget.TextView +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +fun mainThread(delay: Long = 1000, action: () -> Unit) { + Handler(Looper.getMainLooper()).postDelayed({ + action() + }, delay) +} + +fun clickWithDebounce(view: View, debounceTime: Long = 600L, action: () -> Unit) { + view.setOnClickListener(object : View.OnClickListener { + private var lastClickTime: Long = 0 + + override fun onClick(v: View) { + if (SystemClock.elapsedRealtime() - lastClickTime < debounceTime) { + return + } else { + action() + } + + lastClickTime = SystemClock.elapsedRealtime() + } + }) +} + +fun TextView.makeLinks(vararg links: Pair) { + val spannableString = SpannableString(this.text) + var startIndexOfLink = -1 + for (link in links) { + val clickableSpan = object : ClickableSpan() { + override fun updateDrawState(textPaint: TextPaint) { + // use this to change the link color + textPaint.color = textPaint.linkColor + // toggle below value to enable/disable + // the underline shown below the clickable text + // textPaint.isUnderlineText = true + } + + override fun onClick(view: View) { + Selection.setSelection((view as TextView).text as Spannable, 0) + view.invalidate() + link.second.onClick(view) + } + } + startIndexOfLink = this.text.toString().indexOf(link.first, startIndexOfLink + 1) + spannableString.setSpan( + clickableSpan, + startIndexOfLink, + startIndexOfLink + link.first.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + this.movementMethod = + LinkMovementMethod.getInstance() // without LinkMovementMethod, link can not click + this.setText(spannableString, TextView.BufferType.SPANNABLE) +} + +fun Long.isCurrentYear(yearToCompare: String?): Boolean { + val simpleDateFormat = SimpleDateFormat("yyyy", Locale.getDefault()) + val currentYear = simpleDateFormat.format(Date(this)) + return currentYear == yearToCompare +} + +fun Long.getFormattedStringDate(format: String): String { + val simpleDateFormat = SimpleDateFormat(format, Locale.getDefault()) + return simpleDateFormat.format(Date(this)) +} + +fun TrashbinFile.toOCFile(): OCFile { + val ocFile = OCFile(this.remotePath) + ocFile.mimeType = this.mimeType + ocFile.fileLength = this.fileLength + ocFile.remoteId = this.remoteId + ocFile.fileName = this.fileName + return ocFile +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileActivityExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileActivityExtensions.kt new file mode 100644 index 000000000000..d558b38014fd --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileActivityExtensions.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.activity.OnFilesRemovedListener + +fun FileActivity.removeFiles( + offlineFiles: List, + files: List, + onlyLocalCopy: Boolean, + filesRemovedListener: OnFilesRemovedListener? +) { + connectivityService.isNetworkAndServerAvailable { isAvailable -> + if (isAvailable) { + showLoadingDialog(getString(R.string.wait_a_moment)) + + (this as? FileDisplayActivity) + ?.deleteBatchTracker + ?.startBatchDelete(files.size) + + if (files.isNotEmpty()) { + val inBackground = (files.size != 1) + fileOperationsHelper?.removeFiles(files, onlyLocalCopy, inBackground) + } + + if (offlineFiles.isNotEmpty()) { + filesRemovedListener?.onFilesRemoved() + } + + dismissLoadingDialog() + } else { + if (onlyLocalCopy) { + fileOperationsHelper?.removeFiles(files, true, true) + } else { + files.forEach(storageManager::addRemoveFileOfflineOperation) + } + + filesRemovedListener?.onFilesRemoved() + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt new file mode 100644 index 000000000000..d98a06cb4138 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.client.database.entity.toOCCapability +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.status.OCCapability +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +suspend fun FileDataStorageManager.saveShares(shares: List, accountName: String) { + withContext(Dispatchers.IO) { + val entities = shares.map { share -> + share.toEntity(accountName) + } + + shareDao.insertAll(entities) + } +} + +fun FileDataStorageManager.searchFilesByName(file: OCFile, accountName: String, query: String): List = + fileDao.searchFilesInFolder(file.fileId, accountName, query).map { + createFileInstance(it) + } + +fun FileDataStorageManager.getDecryptedPath(file: OCFile): String { + val paths = mutableListOf() + var entity = fileDao.getFileByEncryptedRemotePath(file.remotePath, user.accountName) + + while (entity != null) { + entity.name?.takeIf { it.isNotEmpty() }?.let { + paths.add(it.removePrefix(OCFile.PATH_SEPARATOR)) + } + entity = entity.parent?.let { fileDao.getFileById(it) } ?: break + } + + return paths + .reversed() + .joinToString(OCFile.PATH_SEPARATOR) +} + +fun FileDataStorageManager.getNonEncryptedSubfolders(id: Long, accountName: String): List = + fileDao.getNonEncryptedSubfolders(id, accountName).map { + createFileInstance(it) + } + +suspend fun FileDataStorageManager.getCapabilitiesByAccountName(accountName: String): OCCapability = + capabilityDao.getByAccountName(accountName).toOCCapability() diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt new file mode 100644 index 000000000000..1804550bff03 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import androidx.exifinterface.media.ExifInterface +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.DisplayUtils +import java.io.File +import java.nio.file.Path + +private const val TAG = "FileExtensions" + +fun OCFile?.logFileSize(tag: String) { + val size = DisplayUtils.bytesToHumanReadable(this?.fileLength ?: -1) + val rawByte = this?.fileLength ?: -1 + Log_OC.d(tag, "onSaveInstanceState: $size, raw byte $rawByte") +} + +fun File?.logFileSize(tag: String) { + val size = DisplayUtils.bytesToHumanReadable(this?.length() ?: -1) + val rawByte = this?.length() ?: -1 + Log_OC.d(tag, "onSaveInstanceState: $size, raw byte $rawByte") +} + +fun Path.toLocalPath(): String = toAbsolutePath().toString() + +/** + * Converts a non-null and non-empty [String] path into a [File] object, if it exists. + * + * @receiver String path to a file. + * @return [File] instance if the file exists, or `null` if the path is null, empty, or non-existent. + */ +@Suppress("ReturnCount") +fun String.toFile(): File? { + if (isNullOrEmpty()) { + Log_OC.w(TAG, "given path is null or empty: $this") + return null + } + + val file = File(this) + if (!file.exists()) { + Log_OC.e(TAG, "File does not exist: $this") + return null + } + + return file +} + +fun String.getExifSize(): Pair? = try { + val exif = ExifInterface(this) + var w = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0) + var h = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0) + + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || + orientation == ExifInterface.ORIENTATION_ROTATE_270 + ) { + val tmp = w + w = h + h = tmp + } + + Log_OC.d(TAG, "Using exif imageDimension: $w x $h") + if (w > 0 && h > 0) w to h else null +} catch (_: Exception) { + null +} + +fun String.getBitmapSize(): Pair? = try { + val options = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + android.graphics.BitmapFactory.decodeFile(this, options) + val w = options.outWidth + val h = options.outHeight + + Log_OC.d(TAG, "Using bitmap factory imageDimension: $w x $h") + if (w > 0 && h > 0) w to h else null +} catch (_: Exception) { + null +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FragmentExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FragmentExtensions.kt new file mode 100644 index 000000000000..fafa7fde0495 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/FragmentExtensions.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle + +inline fun Fragment.typedActivity(): T? = if (isAdded && activity != null && activity is T) { + activity as T +} else { + null +} + +/** + * Extension for Java Classes + */ +fun Fragment.getTypedActivity(type: Class): T? = + if (isAdded && activity != null && type.isInstance(activity)) { + type.cast(activity) + } else { + null + } + +fun Fragment.isDialogFragmentReady() = + isAdded && !isStateSaved && activity?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt new file mode 100644 index 000000000000..896d26dca84e --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.util.TypedValue +import android.view.ViewOutlineProvider +import android.widget.ImageView +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import com.owncloud.android.R + +@JvmOverloads +fun ImageView.makeRoundedWithIcon( + context: Context, + @DrawableRes icon: Int, + paddingDp: Int = 6, + @ColorInt backgroundColor: Int = ContextCompat.getColor(context, R.color.primary), + @ColorInt foregroundColor: Int = ContextCompat.getColor(context, R.color.white) +) { + setImageResource(icon) + + val drawable = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(backgroundColor) + } + + background = drawable + clipToOutline = true + scaleType = ImageView.ScaleType.CENTER_INSIDE + outlineProvider = ViewOutlineProvider.BACKGROUND + + setColorFilter(foregroundColor) + + val paddingPx = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + paddingDp.toFloat(), + context.resources.displayMetrics + ).toInt() + + setPadding(paddingPx, paddingPx, paddingPx, paddingPx) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/IntExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/IntExtensions.kt new file mode 100644 index 000000000000..df10cbca1143 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/IntExtensions.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import java.nio.ByteBuffer + +@Suppress("MagicNumber") +fun IntArray.toByteArray(): ByteArray { + val byteBuffer = ByteBuffer.allocate(this.size * 4) + val intBuffer = byteBuffer.asIntBuffer() + intBuffer.put(this) + return byteBuffer.array() +} + +@Suppress("MagicNumber") +fun ByteArray.toIntArray(): IntArray { + val intBuffer = ByteBuffer.wrap(this).asIntBuffer() + val intArray = IntArray(this.size / 4) + intBuffer.get(intArray) + return intArray +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/IntentExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/IntentExtensions.kt new file mode 100644 index 000000000000..56da87f978f6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/IntentExtensions.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.extensions + +import android.content.Intent +import android.os.Parcelable +import androidx.core.content.IntentCompat +import java.io.Serializable + +fun Intent?.getSerializableArgument(key: String, type: Class): T? { + if (this == null) { + return null + } + + return IntentCompat.getSerializableExtra(this, key, type) +} + +fun Intent?.getParcelableArgument(key: String, type: Class): T? { + if (this == null) { + return null + } + + return IntentCompat.getParcelableExtra(this, key, type) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/NavigationViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/NavigationViewExtensions.kt new file mode 100644 index 000000000000..9072b146535b --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/NavigationViewExtensions.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.view.Menu +import androidx.core.view.forEach +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.navigation.NavigationView +import com.owncloud.android.R + +fun NavigationView.getSelectedMenuItemId(): Int { + menu.forEach { + if (it.isChecked) { + return it.itemId + } + } + return Menu.NONE +} + +fun highlightNavigationView( + drawerNavigationView: NavigationView?, + bottomNavigationView: BottomNavigationView?, + menuItemId: Int +) { + drawerNavigationView?.setCheckedItem(menuItemId) + + bottomNavigationView?.let { bottomNav -> + val bottomNavItems = setOf(R.id.nav_assistant, R.id.nav_all_files, R.id.nav_favorites, R.id.nav_gallery) + val menuItem = bottomNav.menu.findItem(menuItemId) + + // Uncheck previous item that exists in drawer if this ID doesn't belong to bottom nav + if (menuItemId !in bottomNavItems) { + bottomNav.menu.findItem(bottomNav.selectedItemId)?.isChecked = false + } + + // Highlight new item, skip assistant because Assistant screen doesn't have same bottom navigation bar + if (menuItem != null && menuItem.itemId != R.id.nav_assistant) { + menuItem.isChecked = true + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCCapabilityExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCCapabilityExtensions.kt new file mode 100644 index 000000000000..55483af5715b --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCCapabilityExtensions.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.google.gson.Gson +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OCCapability +import org.json.JSONException + +private val gson = Gson() + +private const val TAG = "OCCapabilityExtensions" +private const val MAX_JSON_BYTES = 512 * 1024 + +/** + * Determines whether **Windows-compatible file (WCF)** restrictions should be applied + * for the current server version and configuration. + * + * Behavior: + * - For **Nextcloud 32 and newer**, WCF enforcement depends on the [`isWCFEnabled`] flag + * provided by the server capabilities. + * - For **Nextcloud 30 and 31**, WCF restrictions are always applied (feature considered enabled). + * - For **versions older than 30**, WCF is not supported, and no restrictions are applied. + * + * @return `true` if WCF restrictions should be enforced based on the server version and configuration; + * `false` otherwise. + */ +fun OCCapability.checkWCFRestrictions(): Boolean = if (version.isNewerOrEqual(NextcloudVersion.nextcloud_32)) { + isWCFEnabled.isTrue +} else { + version.isNewerOrEqual(NextcloudVersion.nextcloud_30) +} + +fun OCCapability.forbiddenFilenames(): List = jsonToList(forbiddenFilenamesJson) + +fun OCCapability.forbiddenFilenameCharacters(): List = jsonToList(forbiddenFilenameCharactersJson) + +fun OCCapability.forbiddenFilenameExtensions(): List = jsonToList(forbiddenFilenameExtensionJson) + +fun OCCapability.forbiddenFilenameBaseNames(): List = jsonToList(forbiddenFilenameBaseNamesJson) + +fun OCCapability.shouldRemoveNonPrintableUnicodeCharactersAndConvertToUTF8(): Boolean = + forbiddenFilenames().isNotEmpty() || + forbiddenFilenameCharacters().isNotEmpty() || + forbiddenFilenameExtensions().isNotEmpty() || + forbiddenFilenameBaseNames().isNotEmpty() + +@Suppress("ReturnCount", "TooGenericExceptionCaught") +fun jsonToList(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + + if (json.length > MAX_JSON_BYTES) { + Log_OC.e(TAG, "jsonToList: JSON exceeds size limit (${json.length} chars), skipping") + return emptyList() + } + + return try { + gson.fromJson(json, Array::class.java)?.toList() ?: emptyList() + } catch (_: Throwable) { + emptyList() + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt new file mode 100644 index 000000000000..51a7f21e523f --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.OCFileDepth +import com.owncloud.android.datamodel.OCFileDepth.DeepLevel +import com.owncloud.android.datamodel.OCFileDepth.FirstLevel +import com.owncloud.android.datamodel.OCFileDepth.Root +import com.owncloud.android.utils.FileStorageUtils + +fun List.filterFilenames(): List = distinctBy { it.fileName } + +fun OCFile.isTempFile(): Boolean { + val context = MainApp.getAppContext() + val appTempPath = FileStorageUtils.getAppTempDirectoryPath(context) + return storagePath?.startsWith(appTempPath) == true +} + +fun OCFile?.isPNG(): Boolean { + if (this == null) { + return false + } + return "image/png".equals(mimeType, ignoreCase = true) +} + +@Suppress("ReturnCount") +fun OCFile?.getDepth(): OCFileDepth? { + if (this == null) { + return null + } + + // Check if it's the root directory + if (this.isRootDirectory) { + return Root + } + + // If parent is root ("/"), this is a direct child of root + val parentPath = this.parentRemotePath ?: return null + if (parentPath == OCFile.ROOT_PATH) { + return FirstLevel + } + + // Otherwise, it's a subdirectory of a subdirectory + return DeepLevel +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCShareExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCShareExtensions.kt new file mode 100644 index 000000000000..b72274cb8a6b --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCShareExtensions.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.client.database.entity.ShareEntity +import com.owncloud.android.lib.resources.shares.OCShare + +fun OCShare?.remainingDownloadLimit(): Int? { + val downloadLimit = this?.fileDownloadLimit ?: return null + return if (downloadLimit.limit > 0) { + downloadLimit.limit - downloadLimit.count + } else { + null + } +} + +fun OCShare.hasFileRequestPermission(): Boolean = (isFolder && shareType?.isPublicOrMail() == true) + +fun List.mergeDistinctByToken(other: List): List = (this + other).distinctBy { it.token } + +fun OCShare.toEntity(accountName: String): ShareEntity = ShareEntity( + id = remoteId.toInt(), // so that db is not keep updating same files + idRemoteShared = remoteId.toInt(), + path = path, + itemSource = itemSource.toInt(), + fileSource = fileSource.toInt(), + shareType = shareType?.value, + shareWith = shareWith, + permissions = permissions, + sharedDate = sharedDate.toInt(), + expirationDate = expirationDate.toInt(), + token = token, + shareWithDisplayName = sharedWithDisplayName, + isDirectory = if (isFolder) 1 else 0, + userId = userId, + accountOwner = accountName, + isPasswordProtected = if (isPasswordProtected) 1 else 0, + note = note, + hideDownload = if (isHideFileDownload) 1 else 0, + shareLink = shareLink, + shareLabel = label, + attributes = attributes, + downloadLimitLimit = fileDownloadLimit?.limit, + downloadLimitCount = fileDownloadLimit?.count +) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCUploadExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCUploadExtensions.kt new file mode 100644 index 000000000000..b9c4cbba4011 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCUploadExtensions.kt @@ -0,0 +1,69 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.content.Context +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.R +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import com.owncloud.android.files.services.NameCollisionPolicy + +fun List.getUploadIds(): LongArray = map { it.uploadId }.toLongArray() + +fun Array.getUploadIds(): LongArray = map { it.uploadId }.toLongArray() + +fun List.sortedByUploadOrder(): List = sortedWith( + compareBy { it.fixedUploadStatus } + .thenByDescending { it.isFixedUploadingNow } + .thenByDescending { it.fixedUploadEndTimeStamp } + .thenBy { it.fixedUploadId } +) + +fun OCUpload.getStatusText(activity: Context, isGlobalUploadPaused: Boolean, isUploading: Boolean): String { + val status: String + val res = activity.resources + when (val uploadStatus = uploadStatus) { + UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS -> { + status = if (isGlobalUploadPaused) { + res.getString(R.string.upload_global_pause_title) + } else if (isUploading) { + res.getString(R.string.uploader_upload_in_progress_ticker) + } else { + res.getString(R.string.uploads_view_later_waiting_to_upload) + } + } + + UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED -> { + status = if (lastResult == UploadResult.SAME_FILE_CONFLICT) { + res.getString(R.string.uploads_view_upload_status_succeeded_same_file) + } else if (lastResult == UploadResult.FILE_NOT_FOUND) { + lastResult.getFailedStatusText(activity) + } else if (nameCollisionPolicy == NameCollisionPolicy.SKIP) { + res.getString(R.string.uploads_view_upload_status_skip_reason) + } else { + res.getString(R.string.uploads_view_upload_status_succeeded) + } + } + + UploadsStorageManager.UploadStatus.UPLOAD_FAILED -> + status = + lastResult.getFailedStatusText(activity) + + UploadsStorageManager.UploadStatus.UPLOAD_CANCELLED -> + status = + res.getString(R.string.upload_manually_cancelled) + + else -> status = "Uncontrolled status: $uploadStatus" + } + + return status +} + +fun OCUpload.isLastResultConflictError(): Boolean = lastResult in UploadResult.CONFLICT_ERRORS diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OnDataTransferProgressListenerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OnDataTransferProgressListenerExtensions.kt new file mode 100644 index 000000000000..f6fed6fb4f47 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/OnDataTransferProgressListenerExtensions.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener + +@Suppress("MagicNumber") +fun OnDatatransferProgressListener.getPercent(totalTransferredSoFar: Long, totalToTransfer: Long): Int = + ((100.0 * totalTransferredSoFar.toDouble() / totalToTransfer.toDouble()).toInt()).coerceAtMost(100) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt new file mode 100644 index 000000000000..06e4ff1a9b11 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 ZetaTom + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.utils.extensions + +import android.content.Context +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientFactory + +fun OwnCloudClient.toNextcloudClient(context: Context): NextcloudClient = OwnCloudClientFactory.createNextcloudClient( + baseUri, + userId, + credentials.toOkHttpCredentials(), + context, + isFollowRedirects +) + +fun OwnCloudClient.getPreviewEndpoint(remoteId: String, x: Int, y: Int): String = baseUri + .toString() + + "/index.php/core/preview?fileId=" + + remoteId + + "&x=" + (x / 2) + "&y=" + (y / 2) + + "&a=1&mode=cover&forceIcon=0" diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ParcableExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ParcableExtensions.kt new file mode 100644 index 000000000000..86e1a26b0ad2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ParcableExtensions.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.os.Parcel +import android.os.Parcelable +import androidx.core.os.ParcelCompat + +inline fun Parcel?.readParcelableCompat(classLoader: ClassLoader?): T? { + if (this == null) { + return null + } + + return ParcelCompat.readParcelable(this, classLoader, T::class.java) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/RemoteFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/RemoteFileExtensions.kt new file mode 100644 index 000000000000..114849b5a831 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/RemoteFileExtensions.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.utils.TimeConstants +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.utils.FileUtil +import com.owncloud.android.utils.MimeTypeUtil + +fun RemoteFile.isSame(path: String?): Boolean { + val localFile = path?.toFile() ?: return false + + // remote file timestamp in millisecond not microsecond + val localLastModifiedTimestamp = localFile.lastModified() / TimeConstants.MILLIS_PER_SECOND + val localCreationTimestamp = FileUtil.getCreationTimestamp(localFile) + val localSize: Long = localFile.length() + + return size == localSize && + localCreationTimestamp != null && + localCreationTimestamp == creationTimestamp && + modifiedTimestamp == localLastModifiedTimestamp * TimeConstants.MILLIS_PER_SECOND && + this.areImageDimensionsSame(path) +} + +@Suppress("ReturnCount") +private fun RemoteFile.areImageDimensionsSame(path: String): Boolean { + if (!MimeTypeUtil.isImage(mimeType)) { + // can't compare it's not image + return true + } + + val localFileImageDimension = path.getExifSize() ?: path.getBitmapSize() + if (localFileImageDimension == null) { + // can't compare local file image dimension is not determined + return true + } + + return localFileImageDimension.first.toFloat() == imageDimension?.width && + localFileImageDimension.second.toFloat() == imageDimension?.height +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/RemoteOperationResultExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/RemoteOperationResultExtensions.kt new file mode 100644 index 000000000000..e55d49e3e941 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/RemoteOperationResultExtensions.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.utils.ErrorMessageAdapter + +@Suppress("ReturnCount") +fun Pair?, RemoteOperation<*>?>?.getErrorMessage(): String { + val result = this?.first ?: return MainApp.string(R.string.unexpected_error_occurred) + val operation = this.second ?: return MainApp.string(R.string.unexpected_error_occurred) + return ErrorMessageAdapter.getErrorCauseMessage(result, operation, MainApp.getAppContext().resources) +} + +fun ResultCode.isFileSpecificError(): Boolean { + val errorCodes = listOf( + ResultCode.INSTANCE_NOT_CONFIGURED, + ResultCode.QUOTA_EXCEEDED, + ResultCode.LOCAL_STORAGE_FULL, + ResultCode.WRONG_CONNECTION, + ResultCode.UNAUTHORIZED, + ResultCode.OK_NO_SSL, + ResultCode.MAINTENANCE_MODE, + ResultCode.UNTRUSTED_DOMAIN, + ResultCode.ACCOUNT_NOT_THE_SAME, + ResultCode.ACCOUNT_EXCEPTION, + ResultCode.ACCOUNT_NOT_NEW, + ResultCode.ACCOUNT_NOT_FOUND, + ResultCode.ACCOUNT_USES_STANDARD_PASSWORD, + ResultCode.INCORRECT_ADDRESS, + ResultCode.BAD_OC_VERSION + ) + + return !errorCodes.contains(this) +} + +fun ResultCode.isConflict(): Boolean { + val errorCodes = listOf( + ResultCode.SYNC_CONFLICT, + ResultCode.CONFLICT + ) + + return errorCodes.contains(this) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/SearchResultEntryExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/SearchResultEntryExtensions.kt new file mode 100644 index 000000000000..8a42a2f869ec --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/SearchResultEntryExtensions.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.model.SearchResultEntryType +import com.owncloud.android.lib.common.SearchResultEntry + +fun SearchResultEntry.getType(): SearchResultEntryType { + val value = icon.lowercase() + + fun isAvatarUrl(url: String): Boolean { + val regex = Regex("""^https?://[^/]+/avatar/[^/]+/\d+$""") + return regex.matches(url) + } + + return when { + value.contains("icon-folder") -> SearchResultEntryType.Folder + value.contains("icon-note") -> SearchResultEntryType.Note + value.contains("icon-contacts") -> SearchResultEntryType.Contact + value.contains("icon-calendar") || value.contains("text-calendar") -> SearchResultEntryType.CalendarEvent + value.contains("icon-deck") -> SearchResultEntryType.Deck + value.contains("icon-settings") -> SearchResultEntryType.Settings + value.contains("application-pdf") -> SearchResultEntryType.PDF + value.contains("package-x-generic") -> SearchResultEntryType.Generic + value.contains("x-office-spreadsheet") -> SearchResultEntryType.SpreadSheet + value.contains("x-office-presentation") -> SearchResultEntryType.Presentation + value.contains("x-office-form") -> SearchResultEntryType.Form + value.contains("x-office-form-template") -> SearchResultEntryType.FormTemplate + value.contains("x-office-drawing") -> SearchResultEntryType.Drawing + value.contains("x-office-document") -> SearchResultEntryType.Document + value.contains("whiteboard") -> SearchResultEntryType.Whiteboard + value.contains("text-vcard") -> SearchResultEntryType.TextVCard + value.contains("text-code") -> SearchResultEntryType.TextCode + value.contains("link") -> SearchResultEntryType.Link + value.contains("font") -> SearchResultEntryType.Font + isAvatarUrl(thumbnailUrl) -> SearchResultEntryType.Avatar + else -> SearchResultEntryType.Unknown + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ShareTypeExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ShareTypeExtensions.kt new file mode 100644 index 000000000000..4200fae86634 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ShareTypeExtensions.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.owncloud.android.lib.resources.shares.ShareType + +fun ShareType.isPublicOrMail(): Boolean = (this == ShareType.PUBLIC_LINK || this == ShareType.EMAIL) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/StringExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/StringExtensions.kt new file mode 100644 index 000000000000..ca827301ca76 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/StringExtensions.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.utils.extensions + +fun String.getRandomString(length: Int): String { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + val result = (1..length) + .map { allowedChars.random() } + .joinToString("") + + return this + result +} + +fun String.removeFileExtension(): String { + val dotIndex = lastIndexOf('.') + return if (dotIndex != -1) { + substring(0, dotIndex) + } else { + this + } +} + +fun String.webDavParentPath(): String { + val normalized = this.trimEnd('/') + if (normalized.isEmpty()) return "/" + val parent = normalized.substringBeforeLast('/', "") + return if (parent.isEmpty()) "/" else "$parent/" +} + +@Suppress("ComplexCondition") +fun String?.eTagChanged(eTagOnServer: String?): Boolean { + if (this == null || this.isEmpty() || eTagOnServer == null || eTagOnServer.isEmpty()) { + // provided eTags are empty or null can't compare treat as eTag changed + return true + } + + return !this.equals(eTagOnServer, ignoreCase = true) +} + +fun String.extension(): String { + val lastDot = lastIndexOf('.') + + // return empty string for filenames like ".gitignore" + if (lastDot <= 0 || lastDot == length - 1) { + return "" + } + + return substring(lastDot + 1).lowercase() +} + +fun String.truncateWithEllipsis(limit: Int) = take(limit) + if (length > limit) StringConstants.THREE_DOT else "" + +object StringConstants { + const val SLASH = "/" + const val DOT = "." + const val SPACE = " " + const val THREE_DOT = "..." + const val TEMP = "tmp" +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt new file mode 100644 index 000000000000..991b9b0ee904 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt @@ -0,0 +1,161 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManagerImpl +import com.nextcloud.client.network.ConnectivityService +import com.owncloud.android.R +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.SyncedFolderDisplayItem +import com.owncloud.android.lib.common.utils.Log_OC +import java.io.File + +private const val TAG = "SyncedFolderExtensions" + +/** + * Determines whether a file should be skipped during auto-upload based on folder settings. + */ +@Suppress("ReturnCount") +fun SyncedFolder.shouldSkipFile( + file: File, + lastModified: Long, + creationTime: Long?, + fileSentForUpload: Boolean +): Boolean { + Log_OC.d(TAG, "Checking file: ${file.name}, lastModified=$lastModified, lastScan=$lastScanTimestampMs") + + if (isExcludeHidden && file.isHidden) { + Log_OC.d(TAG, "Skipping hidden: ${file.absolutePath}") + return true + } + + // If "upload existing files" is DISABLED, only upload files created after enabled time + if (!isExisting) { + if (creationTime != null) { + if (creationTime < enabledTimestampMs) { + Log_OC.d(TAG, "Skipping pre-existing file (creation < enabled): ${file.absolutePath}") + return true + } + } else { + Log_OC.w(TAG, "file will be inserted to db - cannot determine creation time: ${file.absolutePath}") + return false + } + } + + // Skip files that haven't changed since last scan ONLY if they were sent for upload + // AND only if this is not the first scan + if (fileSentForUpload && lastScanTimestampMs != -1L && lastModified < lastScanTimestampMs) { + Log_OC.d( + TAG, + "Skipping unchanged file that was already sent for upload (last modified < last scan): " + + "${file.absolutePath}" + ) + return true + } + + return false +} + +fun List.filterEnabledOrWithoutEnabledParent(): List = filter { + it.isEnabled || !hasEnabledParent(it.localPath) +} + +@Suppress("ReturnCount") +fun List.hasEnabledParent(localPath: String?): Boolean { + localPath ?: return false + + val localFile = File(localPath).takeIf { it.exists() } ?: return false + val parent = localFile.parentFile ?: return false + + return any { it.isEnabled && File(it.localPath).exists() && File(it.localPath) == parent } || + hasEnabledParent(parent.absolutePath) +} + +@Suppress("MagicNumber", "ReturnCount") +fun SyncedFolder.calculateScanInterval( + connectivityService: ConnectivityService, + powerManagementService: PowerManagementService +): Pair { + val defaultIntervalMillis = BackgroundJobManagerImpl.DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES * 60_000L + + if (!connectivityService.isConnected() || connectivityService.isInternetWalled()) { + return defaultIntervalMillis * 2 to null + } + + if (isWifiOnly && !connectivityService.getConnectivity().isWifi) { + return defaultIntervalMillis * 4 to R.string.auto_upload_wifi_only_warning_info + } + + val batteryLevel = powerManagementService.battery.level + return when { + batteryLevel < 20 -> defaultIntervalMillis * 8 to R.string.auto_upload_low_battery_warning_info + batteryLevel < 50 -> defaultIntervalMillis * 4 to R.string.auto_upload_low_battery_warning_info + batteryLevel < 80 -> defaultIntervalMillis * 2 to null + else -> defaultIntervalMillis to null + } +} + +/** + * Builds a structured debug string of the SyncedFolder configuration. + * + * uploadAction: + * Represents the UI option: + * 👉 "Original file will be..." + * (e.g., kept, deleted, moved after upload) + * + * nameCollisionPolicy: + * Represents the UI option: + * 👉 "What to do if the file already exists?" + * (e.g., rename, overwrite, skip) + * + * subfolderByDate: + * Represents the UI toggle: + * 👉 "Use subfolders" + * + * existing: + * Represents the UI option: + * 👉 "Also upload existing files" + * If false → only files created AFTER enabling are uploaded. + */ +fun SyncedFolder.getLog(): String { + val mediaType = when (type) { + MediaFolderType.IMAGE -> "🖼️ Images" + MediaFolderType.VIDEO -> "🎬 Videos" + MediaFolderType.CUSTOM -> "📁 Custom" + } + + return """ + 📦 Synced Folder + ───────────────────────── + 🆔 ID: $id + 👤 Account: $account + + 📂 Local: $localPath + ☁️ Remote: $remotePath + + $mediaType + 📅 Subfolder rule: ${subfolderRule ?: "None"} + 🗂️ By date: $isSubfolderByDate + 🙈 Exclude hidden: $isExcludeHidden + 👀 Hidden config: $isHidden + + 📶 Wi-Fi only: $isWifiOnly + 🔌 Charging only: $isChargingOnly + + 📤 Upload existing files: $isExisting + ⚙️ Upload action: $uploadAction + 🧩 Name collision: $nameCollisionPolicy + + ✅ Enabled: $isEnabled + 🕒 Enabled at: $enabledTimestampMs + 🔍 Last scan: $lastScanTimestampMs + ───────────────────────── + """.trimIndent() +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/TaskTypeExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/TaskTypeExtensions.kt new file mode 100644 index 000000000000..5fca6d0ab98e --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/TaskTypeExtensions.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData + +fun List?.getChat(): TaskTypeData? = this?.find { it.isChat() } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/TextViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/TextViewExtensions.kt new file mode 100644 index 000000000000..11e622f420e8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/TextViewExtensions.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.extensions + +import android.text.method.LinkMovementMethod +import android.widget.TextView +import androidx.core.text.HtmlCompat + +@Suppress("NewLineAtEndOfFile") +fun TextView.setHtmlContent(value: String) { + movementMethod = LinkMovementMethod.getInstance() + text = HtmlCompat.fromHtml(value, HtmlCompat.FROM_HTML_MODE_LEGACY) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ThumbnailsCacheManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ThumbnailsCacheManagerExtensions.kt new file mode 100644 index 000000000000..962c586c5173 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ThumbnailsCacheManagerExtensions.kt @@ -0,0 +1,77 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.provider.MediaStore +import androidx.exifinterface.media.ExifInterface +import androidx.core.net.toUri +import com.owncloud.android.MainApp +import com.owncloud.android.lib.common.utils.Log_OC + +/** + * Retrieves the orientation of an image file from its EXIF metadata or, as a fallback, + * from the Android MediaStore. + * + * This function first attempts to read the orientation using [ExifInterface.TAG_ORIENTATION] + * directly from the file at the given [path]. If that fails or returns + * [ExifInterface.ORIENTATION_UNDEFINED], it then queries the MediaStore for the image's + * stored orientation in degrees (0, 90, 180, or 270), converting that to an EXIF-compatible + * orientation constant. + * + * @param path Absolute file path or content URI (as string) of the image. + * @return One of the [ExifInterface] orientation constants, e.g. + * [ExifInterface.ORIENTATION_ROTATE_90], or [ExifInterface.ORIENTATION_UNDEFINED] + * if the orientation could not be determined. + * + * @see ExifInterface + * @see MediaStore.Images.Media.ORIENTATION + */ +@Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "MagicNumber") +fun getExifOrientation(path: String): Int { + val context = MainApp.getAppContext() + if (context == null || path.isBlank()) { + return ExifInterface.ORIENTATION_UNDEFINED + } + + var orientation = ExifInterface.ORIENTATION_UNDEFINED + + try { + val exif = ExifInterface(path) + orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_UNDEFINED + ) + } catch (e: Exception) { + Log_OC.e("ThumbnailsCacheManager", "getExifOrientation exception: $e") + } + + // Fallback: query MediaStore if EXIF is undefined + if (orientation == ExifInterface.ORIENTATION_UNDEFINED) { + try { + val uri = path.toUri() + val projection = arrayOf(MediaStore.Images.Media.ORIENTATION) + + context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val orientationIndex = cursor.getColumnIndexOrThrow(projection[0]) + val degrees = cursor.getInt(orientationIndex) + orientation = when (degrees) { + 90 -> ExifInterface.ORIENTATION_ROTATE_90 + 180 -> ExifInterface.ORIENTATION_ROTATE_180 + 270 -> ExifInterface.ORIENTATION_ROTATE_270 + else -> ExifInterface.ORIENTATION_NORMAL + } + } + } + } catch (e: Exception) { + Log_OC.e("ThumbnailsCacheManager", "getExifOrientation exception: $e") + } + } + + return orientation +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt new file mode 100644 index 000000000000..4ca297751522 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt @@ -0,0 +1,106 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.content.Context +import com.owncloud.android.R +import com.owncloud.android.db.UploadResult + +fun UploadResult.isNonRetryable(): Boolean = when (this) { + UploadResult.FILE_NOT_FOUND, + UploadResult.FILE_ERROR, + UploadResult.FOLDER_ERROR, + UploadResult.CANNOT_CREATE_FILE, + UploadResult.SYNC_CONFLICT, + UploadResult.CONFLICT_ERROR, + UploadResult.SAME_FILE_CONFLICT, + UploadResult.LOCAL_STORAGE_NOT_COPIED, + UploadResult.VIRUS_DETECTED, + UploadResult.QUOTA_EXCEEDED, + UploadResult.PRIVILEGES_ERROR, + UploadResult.CREDENTIAL_ERROR, + + // most cases covered and mapped from RemoteOperationResult. Most likely UploadResult.UNKNOWN this error will + // occur again + UploadResult.UNKNOWN, + + // user's choice + UploadResult.CANCELLED -> true + + // everything else may succeed after retry + else -> false +} + +fun UploadResult.getFailedStatusText(context: Context): String = when (this) { + UploadResult.CREDENTIAL_ERROR -> + context.getString(R.string.uploads_view_upload_status_failed_credentials_error) + + UploadResult.FOLDER_ERROR -> + context.getString(R.string.uploads_view_upload_status_failed_folder_error) + + UploadResult.FILE_NOT_FOUND -> + context.getString(R.string.uploads_view_upload_status_failed_localfile_error) + + UploadResult.FILE_ERROR -> context.getString(R.string.uploads_view_upload_status_failed_file_error) + + UploadResult.PRIVILEGES_ERROR -> context.getString( + R.string.uploads_view_upload_status_failed_permission_error + ) + + UploadResult.NETWORK_CONNECTION -> + context.getString(R.string.uploads_view_upload_status_failed_connection_error) + + UploadResult.DELAYED_FOR_WIFI -> context.getString( + R.string.uploads_view_upload_status_waiting_for_wifi + ) + + UploadResult.DELAYED_FOR_CHARGING -> + context.getString(R.string.uploads_view_upload_status_waiting_for_charging) + + UploadResult.CONFLICT_ERROR -> context.getString(R.string.uploads_view_upload_status_conflict) + + UploadResult.SERVICE_INTERRUPTED -> context.getString( + R.string.uploads_view_upload_status_service_interrupted + ) + + UploadResult.CANCELLED -> // should not get here ; cancelled uploads should be wiped out + context.getString(R.string.uploads_view_upload_status_cancelled) + + UploadResult.UPLOADED -> // should not get here ; status should be UPLOAD_SUCCESS + context.getString(R.string.uploads_view_upload_status_succeeded) + + UploadResult.MAINTENANCE_MODE -> context.getString(R.string.maintenance_mode) + + UploadResult.SSL_RECOVERABLE_PEER_UNVERIFIED -> context.getString( + R.string.uploads_view_upload_status_failed_ssl_certificate_not_trusted + ) + + UploadResult.UNKNOWN -> context.getString(R.string.uploads_view_upload_status_unknown_fail) + + UploadResult.LOCK_FAILED -> context.getString(R.string.upload_lock_failed) + + UploadResult.DELAYED_IN_POWER_SAVE_MODE -> context.getString( + R.string.uploads_view_upload_status_waiting_exit_power_save_mode + ) + + UploadResult.VIRUS_DETECTED -> context.getString(R.string.uploads_view_upload_status_virus_detected) + + UploadResult.LOCAL_STORAGE_FULL -> context.getString(R.string.upload_local_storage_full) + + UploadResult.OLD_ANDROID_API -> context.getString(R.string.upload_old_android) + + UploadResult.SYNC_CONFLICT -> context.getString(R.string.upload_sync_conflict_check) + + UploadResult.CANNOT_CREATE_FILE -> context.getString(R.string.upload_cannot_create_file) + + UploadResult.LOCAL_STORAGE_NOT_COPIED -> context.getString(R.string.upload_local_storage_not_copied) + + UploadResult.QUOTA_EXCEEDED -> context.getString(R.string.upload_quota_exceeded) + + else -> context.getString(R.string.upload_unknown_error) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UploadStorageManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UploadStorageManagerExtensions.kt new file mode 100644 index 000000000000..e86a9f0f8ec3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/UploadStorageManagerExtensions.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.client.database.entity.UploadEntity +import com.owncloud.android.datamodel.UploadsStorageManager + +fun UploadsStorageManager.updateStatus(entity: UploadEntity?, status: UploadsStorageManager.UploadStatus) { + entity ?: return + uploadDao.insertOrReplace(entity.withStatus(status)) +} + +fun UploadsStorageManager.updateStatus(entity: UploadEntity?, success: Boolean) { + entity ?: return + val newStatus = if (success) { + UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED + } else { + UploadsStorageManager.UploadStatus.UPLOAD_FAILED + } + uploadDao.insertOrReplace(entity.withStatus(newStatus)) +} + +private fun UploadEntity.withStatus(newStatus: UploadsStorageManager.UploadStatus) = this.copy(status = newStatus.value) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt new file mode 100644 index 000000000000..36195f78c2d3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt @@ -0,0 +1,105 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.extensions + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.content.Context +import android.graphics.Outline +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.nextcloud.ui.behavior.OnScrollBehavior +import com.owncloud.android.lib.common.utils.Log_OC + +fun View?.setVisibleIf(condition: Boolean) { + if (this == null) return + visibility = if (condition) View.VISIBLE else View.GONE +} + +fun View?.setVisibilityWithAnimation(condition: Boolean, duration: Long = 200L) { + this ?: return + + if (condition) { + this.apply { + alpha = 0f + visibility = View.VISIBLE + animate() + .alpha(1f) + .setDuration(duration) + .setListener(null) + } + } else { + animate() + .alpha(0f) + .setDuration(duration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + visibility = View.GONE + } + }) + } +} + +fun View?.makeRounded(context: Context, cornerRadius: Float) { + this?.let { + it.apply { + outlineProvider = createRoundedOutline(context, cornerRadius) + clipToOutline = true + } + } +} + +fun View?.setMargins(left: Int, top: Int, right: Int, bottom: Int) { + if (this == null) { + return + } + + if (layoutParams is ViewGroup.MarginLayoutParams) { + val param = layoutParams as ViewGroup.MarginLayoutParams + param.setMargins(left, top, right, bottom) + requestLayout() + } +} + +fun createRoundedOutline(context: Context, cornerRadiusValue: Float): ViewOutlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val left = 0 + val top = 0 + val right = view.width + val bottom = view.height + val cornerRadius = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + cornerRadiusValue, + context.resources.displayMetrics + ).toInt() + + outline.setRoundRect(left, top, right, bottom, cornerRadius.toFloat()) + } + } + +@Suppress("UNCHECKED_CAST", "ReturnCount", "TooGenericExceptionCaught") +fun T.slideHideBottomBehavior(visible: Boolean) { + this ?: return + val params = layoutParams as? CoordinatorLayout.LayoutParams ?: return + val behavior = params.behavior as? OnScrollBehavior ?: return + post { + try { + if (visible) { + behavior.slideIn(this) + } else { + behavior.slideOut(this) + } + } catch (e: Exception) { + Log_OC.e("slideHideBottomBehavior", e.message) + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/WorkManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/WorkManagerExtensions.kt new file mode 100644 index 000000000000..9a3a8b69c7b2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/WorkManagerExtensions.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.extensions + +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.google.common.util.concurrent.ListenableFuture +import com.owncloud.android.lib.common.utils.Log_OC +import java.util.concurrent.ExecutionException + +private const val TAG = "WorkManager" + +fun WorkManager.isWorkRunning(tag: String): Boolean = checkWork(tag, listOf(WorkInfo.State.RUNNING)) + +fun WorkManager.isWorkScheduled(tag: String): Boolean = + checkWork(tag, listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED)) + +private fun WorkManager.checkWork(tag: String, stateConditions: List): Boolean { + val statuses: ListenableFuture> = getWorkInfosByTag(tag) + var workInfoList: List = emptyList() + + try { + workInfoList = statuses.get() + } catch (e: ExecutionException) { + Log_OC.d(TAG, "ExecutionException in checkWork: $e") + } catch (e: InterruptedException) { + Log_OC.d(TAG, "InterruptedException in checkWork: $e") + } + + return workInfoList.any { workInfo -> stateConditions.contains(workInfo.state) } +} diff --git a/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameTextWatcher.kt b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameTextWatcher.kt new file mode 100644 index 000000000000..3ec4ce2963b1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameTextWatcher.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.fileNameValidator + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import androidx.core.util.Consumer +import com.nextcloud.utils.fileNameValidator.FileNameValidator.checkFileName +import com.nextcloud.utils.fileNameValidator.FileNameValidator.isExtensionChanged +import com.nextcloud.utils.fileNameValidator.FileNameValidator.isFileHidden +import com.owncloud.android.R +import com.owncloud.android.lib.resources.status.OCCapability + +/** + * A TextWatcher which wraps around [FileNameValidator] + */ +@Suppress("LongParameterList") +class FileNameTextWatcher( + private val previousFileName: String?, + private val context: Context, + private val capabilitiesProvider: () -> OCCapability, + private val existingFileNamesProvider: () -> Set?, + private val onValidationError: Consumer, + private val onValidationWarning: Consumer, + private val onValidationSuccess: Runnable +) : TextWatcher { + + // Used to trigger the onValidationSuccess callback only once (on "error/warn -> valid" transition) + private var isNameCurrentlyValid: Boolean = true + + override fun afterTextChanged(s: Editable?) { + val currentFileName = s?.toString().orEmpty() + val validationError = checkFileName( + currentFileName, + capabilitiesProvider(), + context, + existingFileNamesProvider() + ) + + when { + isFileHidden(currentFileName) -> { + isNameCurrentlyValid = false + onValidationWarning.accept(context.getString(R.string.hidden_file_name_warning)) + } + + validationError != null -> { + isNameCurrentlyValid = false + onValidationError.accept(validationError) + } + + isExtensionChanged(previousFileName, currentFileName) -> { + isNameCurrentlyValid = false + onValidationWarning.accept(context.getString(R.string.warn_rename_extension)) + } + + !isNameCurrentlyValid -> { + isNameCurrentlyValid = true + onValidationSuccess.run() + } + } + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit +} diff --git a/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt new file mode 100644 index 000000000000..eaf4c8fc829b --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt @@ -0,0 +1,162 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.fileNameValidator + +import android.content.Context +import android.text.TextUtils +import com.nextcloud.utils.extensions.StringConstants +import com.nextcloud.utils.extensions.checkWCFRestrictions +import com.nextcloud.utils.extensions.extension +import com.nextcloud.utils.extensions.forbiddenFilenameBaseNames +import com.nextcloud.utils.extensions.forbiddenFilenameCharacters +import com.nextcloud.utils.extensions.forbiddenFilenameExtensions +import com.nextcloud.utils.extensions.forbiddenFilenames +import com.nextcloud.utils.extensions.removeFileExtension +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.status.OCCapability + +object FileNameValidator { + + /** + * Checks the validity of a file name. + * + * @param filename The name of the file to validate. + * @param capability The capabilities affecting the validation criteria + * such as forbiddenFilenames, forbiddenCharacters. + * @param context The context used for retrieving error messages. + * @param existingFileNames Set of existing file names to avoid duplicates. + * @return An error message if the filename is invalid, null otherwise. + */ + @Suppress("ReturnCount", "NestedBlockDepth") + fun checkFileName( + filename: String, + capability: OCCapability, + context: Context, + existingFileNames: Set? = null + ): String? { + if (filename.isBlank()) { + return context.getString(R.string.filename_empty) + } + + existingFileNames?.let { + if (isFileNameAlreadyExist(filename, existingFileNames)) { + return context.getString(R.string.file_already_exists) + } + } + + if (!capability.checkWCFRestrictions()) { + return null + } + + // region WCF related checks + checkInvalidCharacters(filename, capability, context)?.let { return it } + + val filenameVariants = setOf(filename.lowercase(), filename.removeFileExtension().lowercase()) + + with(capability) { + forbiddenFilenameBaseNamesJson?.let { + forbiddenFilenameBaseNames().find { it.lowercase() in filenameVariants }?.let { forbiddenBaseFilename -> + return context.getString(R.string.file_name_validator_error_reserved_names, forbiddenBaseFilename) + } + } + + forbiddenFilenamesJson?.let { + forbiddenFilenames().find { it.lowercase() in filenameVariants }?.let { forbiddenFilename -> + return context.getString(R.string.file_name_validator_error_reserved_names, forbiddenFilename) + } + } + + forbiddenFilenameExtensionJson?.let { + forbiddenFilenameExtensions().find { extension -> + when { + extension == StringConstants.SPACE -> + filename.startsWith(extension, ignoreCase = true) || + filename.endsWith(extension, ignoreCase = true) + + else -> filename.endsWith(extension, ignoreCase = true) + } + }?.let { forbiddenExtension -> + return if (forbiddenExtension == StringConstants.SPACE) { + context.getString(R.string.file_name_validator_error_forbidden_space_character_extensions) + } else { + context.getString( + R.string.file_name_validator_error_forbidden_file_extensions, + forbiddenExtension + ) + } + } + } + } + // endregion + + return null + } + + /** + * Checks the validity of file paths wanted to move or copied inside the folder. + * + * @param folderPath Target folder to be used for move or copy. + * @param filePaths The list of file paths to move or copy to folderPath. + * @param capability The capabilities affecting the validation criteria. + * @param context The context used for retrieving error messages. + * @return True if folder path and file paths are valid, false otherwise. + */ + fun checkFolderAndFilePaths( + folderPath: String, + filePaths: List, + capability: OCCapability, + context: Context + ): Boolean = checkFolderPath(folderPath, capability, context) && checkFilePaths(filePaths, capability, context) + + fun checkParentRemotePaths(filePaths: List, capability: OCCapability, context: Context): Boolean = + filePaths.all { + if (it.parentRemotePath != StringConstants.SLASH) { + val parentFolderName = it.parentRemotePath.replace(StringConstants.SLASH, "") + checkFileName(parentFolderName, capability, context) == null + } else { + true + } + } + + private fun checkFilePaths(filePaths: List, capability: OCCapability, context: Context): Boolean = + filePaths.all { + checkFileName(it, capability, context) == null + } + + fun checkFolderPath(folderPath: String, capability: OCCapability, context: Context): Boolean = + folderPath.split("[/\\\\]".toRegex()) + .none { it.isNotEmpty() && checkFileName(it, capability, context) != null } + + @Suppress("ReturnCount") + private fun checkInvalidCharacters(name: String, capability: OCCapability, context: Context): String? { + capability.forbiddenFilenameCharactersJson?.let { + val forbiddenFilenameCharacters = capability.forbiddenFilenameCharacters() + + val invalidCharacter = forbiddenFilenameCharacters.firstOrNull { name.contains(it) } + + if (invalidCharacter == null) return null + + return context.getString(R.string.file_name_validator_error_invalid_character, invalidCharacter) + } + + return null + } + + fun isExtensionChanged(previousFileName: String?, newFileName: String?): Boolean { + if (previousFileName == null || newFileName == null) { + return previousFileName != newFileName + } + + return previousFileName.extension() != newFileName.extension() + } + + fun isFileHidden(name: String): Boolean = !TextUtils.isEmpty(name) && name[0] == '.' + + fun isFileNameAlreadyExist(name: String, fileNames: Set): Boolean = fileNames.contains(name) +} diff --git a/app/src/main/java/com/nextcloud/utils/mdm/MDMConfig.kt b/app/src/main/java/com/nextcloud/utils/mdm/MDMConfig.kt new file mode 100644 index 000000000000..8443636e60e5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/mdm/MDMConfig.kt @@ -0,0 +1,136 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.mdm + +import android.content.Context +import android.content.RestrictionsManager +import com.owncloud.android.R +import com.owncloud.android.utils.appConfig.AppConfigKeys + +object MDMConfig { + fun multiAccountSupport(context: Context): Boolean { + val multiAccountSupport = context.resources.getBoolean(R.bool.multiaccount_support) + + val disableMultiAccountViaMDM = context.getRestriction( + AppConfigKeys.DisableMultiAccount, + context.resources.getBoolean(R.bool.disable_multiaccount) + ) + + return multiAccountSupport && !disableMultiAccountViaMDM + } + + fun shareViaLink(context: Context): Boolean { + val disableShareViaMDM = context.getRestriction( + AppConfigKeys.DisableSharing, + context.resources.getBoolean(R.bool.disable_sharing) + ) + + val shareViaLink = context.resources.getBoolean(R.bool.share_via_link_feature) + + return shareViaLink && !disableShareViaMDM + } + + fun shareViaUser(context: Context): Boolean { + val disableShareViaMDM = context.getRestriction( + AppConfigKeys.DisableSharing, + context.resources.getBoolean(R.bool.disable_sharing) + ) + + val shareViaUsers = context.resources.getBoolean(R.bool.share_with_users_feature) + + return shareViaUsers && !disableShareViaMDM + } + + fun sendFilesSupport(context: Context): Boolean { + val disableShareViaMDM = context.getRestriction( + AppConfigKeys.DisableSharing, + context.resources.getBoolean(R.bool.disable_sharing) + ) + + val sendFilesToOtherApp = "on".equals(context.getString(R.string.send_files_to_other_apps), ignoreCase = true) + + return sendFilesToOtherApp && !disableShareViaMDM + } + + fun sharingSupport(context: Context): Boolean { + val disableShareViaMDM = context.getRestriction( + AppConfigKeys.DisableSharing, + context.resources.getBoolean(R.bool.disable_sharing) + ) + + val sendFilesToOtherApp = "on".equals(context.getString(R.string.send_files_to_other_apps), ignoreCase = true) + + val shareViaUsers = context.resources.getBoolean(R.bool.share_with_users_feature) + + val shareViaLink = context.resources.getBoolean(R.bool.share_via_link_feature) + + return sendFilesToOtherApp && shareViaLink && shareViaUsers && !disableShareViaMDM + } + + fun clipBoardSupport(context: Context): Boolean { + val disableClipboardSupport = context.getRestriction( + AppConfigKeys.DisableClipboard, + context.resources.getBoolean(R.bool.disable_clipboard) + ) + + return !disableClipboardSupport + } + + fun externalSiteSupport(context: Context): Boolean { + val disableMoreExternalSiteViaMDM = context.getRestriction( + AppConfigKeys.DisableMoreExternalSite, + context.resources.getBoolean(R.bool.disable_more_external_site) + ) + + val showExternalLinks = context.resources.getBoolean(R.bool.show_external_links) + + return showExternalLinks && !disableMoreExternalSiteViaMDM + } + + fun showIntro(context: Context): Boolean { + val disableIntroViaMDM = + context.getRestriction(AppConfigKeys.DisableIntro, context.resources.getBoolean(R.bool.disable_intro)) + + val isProviderOrOwnInstallationVisible = context.resources.getBoolean(R.bool.show_provider_or_own_installation) + + return isProviderOrOwnInstallationVisible && !disableIntroViaMDM + } + + fun isLogEnabled(context: Context): Boolean { + val disableLogViaMDM = + context.getRestriction(AppConfigKeys.DisableLog, context.resources.getBoolean(R.bool.disable_log)) + + val loggerEnabled = context.resources.getBoolean(R.bool.logger_enabled) + + return loggerEnabled && !disableLogViaMDM + } + + fun getBaseUrl(context: Context): String = context.getRestriction(AppConfigKeys.BaseUrl, "") + + fun getHost(context: Context): String = + context.getRestriction(AppConfigKeys.ProxyHost, context.getString(R.string.proxy_host)) + + fun getPort(context: Context): Int = + context.getRestriction(AppConfigKeys.ProxyPort, context.resources.getInteger(R.integer.proxy_port)) + + fun enforceProtection(context: Context): Boolean = + context.getRestriction(AppConfigKeys.EnforceProtection, context.resources.getBoolean(R.bool.enforce_protection)) + + @Suppress("UNCHECKED_CAST") + private fun Context.getRestriction(appConfigKey: AppConfigKeys, defaultValue: T): T { + val restrictionsManager = getSystemService(Context.RESTRICTIONS_SERVICE) as? RestrictionsManager + val appRestrictions = restrictionsManager?.getApplicationRestrictions() ?: return defaultValue + + return when (defaultValue) { + is String -> appRestrictions.getString(appConfigKey.key, defaultValue) as T? ?: defaultValue + is Int -> appRestrictions.getInt(appConfigKey.key, defaultValue) as T? ?: defaultValue + is Boolean -> appRestrictions.getBoolean(appConfigKey.key, defaultValue) as T? ?: defaultValue + else -> defaultValue + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/numberFormatter/NumberFormatter.kt b/app/src/main/java/com/nextcloud/utils/numberFormatter/NumberFormatter.kt new file mode 100644 index 000000000000..d036152af285 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/numberFormatter/NumberFormatter.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.numberFormatter + +import java.text.NumberFormat +import java.util.Locale + +object NumberFormatter { + + @Suppress("MagicNumber") + fun getPercentageText(percent: Int): String { + val formatter = NumberFormat.getPercentInstance(Locale.getDefault()) + formatter.maximumFractionDigits = 0 + return formatter.format(percent / 100.0) + } +} diff --git a/app/src/main/java/com/nextcloud/utils/view/FastScrollPopupBackground.kt b/app/src/main/java/com/nextcloud/utils/view/FastScrollPopupBackground.kt new file mode 100644 index 000000000000..8d716f6ca779 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/view/FastScrollPopupBackground.kt @@ -0,0 +1,144 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Matrix +import android.graphics.Outline +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.View +import androidx.annotation.ColorInt +import androidx.core.graphics.drawable.DrawableCompat +import kotlin.math.sqrt + +/** + * Copied over from [me.zhanghai.android.fastscroll.Md2PopupBackground] on 2022/06/15 + * and adapted for color changing + */ +class FastScrollPopupBackground(context: Context, @ColorInt color: Int) : Drawable() { + + private val mPaint: Paint = Paint() + private val mPaddingStart: Int + private val mPaddingEnd: Int + private val mPath = Path() + private val mTempMatrix = Matrix() + + init { + mPaint.isAntiAlias = true + mPaint.color = color + mPaint.style = Paint.Style.FILL + val resources = context.resources + mPaddingStart = + resources.getDimensionPixelOffset(me.zhanghai.android.fastscroll.R.dimen.afs_md2_popup_padding_start) + mPaddingEnd = + resources.getDimensionPixelOffset(me.zhanghai.android.fastscroll.R.dimen.afs_md2_popup_padding_end) + } + + override fun draw(canvas: Canvas) { + canvas.drawPath(mPath, mPaint) + } + + override fun setAlpha(alpha: Int) { + // noop + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + // noop + } + + override fun isAutoMirrored(): Boolean = true + + override fun getOpacity(): Int = PixelFormat.TRANSPARENT + + private fun shouldMirrorPath(): Boolean = DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL + + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { + updatePath() + return true + } + + override fun onBoundsChange(bounds: Rect) { + updatePath() + } + + @Suppress("MagicNumber") + private fun updatePath() { + mPath.reset() + val bounds = bounds + var width = bounds.width().toFloat() + val height = bounds.height().toFloat() + val r = height / 2 + val sqrt2 = sqrt(2.0).toFloat() + // Ensure we are convex. + width = (r + sqrt2 * r).coerceAtLeast(width) + pathArcTo(mPath, r, r, r, startAngle = 90f, sweepAngle = 180f) + val o1X = width - sqrt2 * r + pathArcTo(mPath, o1X, r, r, startAngle = -90f, sweepAngle = 45f) + val r2 = r / 5 + val o2X = width - sqrt2 * r2 + pathArcTo(mPath, o2X, r, r2, startAngle = -45f, sweepAngle = 90f) + pathArcTo(mPath, o1X, r, r, startAngle = 45f, sweepAngle = 45f) + mPath.close() + if (shouldMirrorPath()) { + mTempMatrix.setScale(-1f, 1f, width / 2, 0f) + } else { + mTempMatrix.reset() + } + mTempMatrix.postTranslate(bounds.left.toFloat(), bounds.top.toFloat()) + mPath.transform(mTempMatrix) + } + + override fun getPadding(padding: Rect): Boolean { + if (shouldMirrorPath()) { + padding[mPaddingEnd, 0, mPaddingStart] = 0 + } else { + padding[mPaddingStart, 0, mPaddingEnd] = 0 + } + return true + } + + override fun getOutline(outline: Outline) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !mPath.isConvex) { + // The outline path must be convex before Q, but we may run into floating point error + // caused by calculation involving sqrt(2) or OEM implementation difference, so in this + // case we just omit the shadow instead of crashing. + super.getOutline(outline) + return + } + outline.setConvexPath(mPath) + } + + companion object { + @Suppress("LongParameterList") + private fun pathArcTo( + path: Path, + centerX: Float, + centerY: Float, + radius: Float, + startAngle: Float, + sweepAngle: Float + ) { + path.arcTo( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius, + startAngle, + sweepAngle, + false + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/view/FastScrollUtils.kt b/app/src/main/java/com/nextcloud/utils/view/FastScrollUtils.kt new file mode 100644 index 000000000000..807d4b3de7e4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/view/FastScrollUtils.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.utils.view + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.AppBarLayout +import com.owncloud.android.utils.theme.ViewThemeUtils +import me.zhanghai.android.fastscroll.FastScroller +import me.zhanghai.android.fastscroll.FastScrollerBuilder +import javax.inject.Inject + +class FastScrollUtils @Inject constructor(private val viewThemeUtils: ViewThemeUtils) { + @JvmOverloads + fun applyFastScroll(recyclerView: RecyclerView, viewHelper: FastScroller.ViewHelper? = null) { + val builder = + FastScrollerBuilder(recyclerView).let { + viewThemeUtils.files.themeFastScrollerBuilder( + recyclerView.context, + it + ) + } + if (viewHelper != null) { + builder.setViewHelper(viewHelper) + } + builder.build() + } + + fun fixAppBarForFastScroll(appBarLayout: AppBarLayout, content: ViewGroup) { + val contentLayoutInitialPaddingBottom = content.paddingBottom + appBarLayout.addOnOffsetChangedListener( + AppBarLayout.OnOffsetChangedListener { _, offset -> + content.setPadding( + content.paddingLeft, + content.paddingTop, + content.paddingRight, + contentLayoutInitialPaddingBottom + appBarLayout.totalScrollRange + offset + ) + } + ) + } +} diff --git a/app/src/main/java/com/nmc/android/ui/LauncherActivity.kt b/app/src/main/java/com/nmc/android/ui/LauncherActivity.kt new file mode 100644 index 000000000000..b39b3f812299 --- /dev/null +++ b/app/src/main/java/com/nmc/android/ui/LauncherActivity.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Andy Scherzinger + * SPDX-FileCopyrightText: 2023-2024 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nmc.android.ui + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.TextUtils +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.mdm.MDMConfig +import com.owncloud.android.R +import com.owncloud.android.authentication.AuthenticatorActivity +import com.owncloud.android.databinding.ActivitySplashBinding +import com.owncloud.android.ui.activity.BaseActivity +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.activity.SettingsActivity +import javax.inject.Inject + +class LauncherActivity : BaseActivity() { + + private lateinit var binding: ActivitySplashBinding + + @Inject + lateinit var appPreferences: AppPreferences + + override fun onCreate(savedInstanceState: Bundle?) { + // Mandatory to call this before super method to show system launch screen for api level 31+ + installSplashScreen() + + super.onCreate(savedInstanceState) + + binding = ActivitySplashBinding.inflate(layoutInflater) + + setContentView(binding.root) + updateTitleVisibility() + scheduleSplashScreen() + } + + @VisibleForTesting + fun setSplashTitles(boldText: String, normalText: String) { + binding.splashScreenBold.visibility = View.VISIBLE + binding.splashScreenNormal.visibility = View.VISIBLE + + binding.splashScreenBold.text = boldText + binding.splashScreenNormal.text = normalText + } + + private fun updateTitleVisibility() { + if (TextUtils.isEmpty(resources.getString(R.string.splashScreenBold))) { + binding.splashScreenBold.visibility = View.GONE + } + if (TextUtils.isEmpty(resources.getString(R.string.splashScreenNormal))) { + binding.splashScreenNormal.visibility = View.GONE + } + } + + private fun scheduleSplashScreen() { + Handler(Looper.getMainLooper()).postDelayed({ + if (user.isPresent) { + if (MDMConfig.enforceProtection(this) && appPreferences.lockPreference == SettingsActivity.LOCK_NONE) { + startActivity(Intent(this, SettingsActivity::class.java)) + } else { + startActivity(Intent(this, FileDisplayActivity::class.java)) + } + } else { + startActivity(Intent(this, AuthenticatorActivity::class.java)) + } + finish() + }, SPLASH_DURATION) + } + + companion object { + const val SPLASH_DURATION = 1500L + } +} diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java new file mode 100644 index 000000000000..bea262738e5b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -0,0 +1,1039 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2022-2023 Álvaro Brey + * SPDX-FileCopyrightText: 2016-2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2019 Alice Gaudon + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-FileCopyrightText: 2013 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.net.ConnectivityManager; +import android.os.Bundle; +import android.os.Environment; +import android.os.StrictMode; +import android.text.TextUtils; +import android.view.WindowManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.nextcloud.appReview.InAppReviewHelper; +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.appinfo.AppInfo; +import com.nextcloud.client.core.Clock; +import com.nextcloud.client.device.PowerManagementService; +import com.nextcloud.client.di.ActivityInjector; +import com.nextcloud.client.di.AppComponent; +import com.nextcloud.client.di.DaggerAppComponent; +import com.nextcloud.client.errorhandling.ExceptionHandler; +import com.nextcloud.client.jobs.BackgroundJobManager; +import com.nextcloud.client.logger.LegacyLoggerAdapter; +import com.nextcloud.client.logger.Logger; +import com.nextcloud.client.migrations.MigrationsManager; +import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.network.WalledCheckCache; +import com.nextcloud.client.onboarding.OnboardingService; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.nextcloud.client.preferences.DarkMode; +import com.nextcloud.receiver.NetworkChangeListener; +import com.nextcloud.receiver.NetworkChangeReceiver; +import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias; +import com.nextcloud.utils.extensions.ContextExtensionsKt; +import com.nextcloud.utils.mdm.MDMConfig; +import com.nmc.android.ui.LauncherActivity; +import com.owncloud.android.authentication.PassCodeManager; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.MediaFolder; +import com.owncloud.android.datamodel.MediaFolderType; +import com.owncloud.android.datamodel.MediaProvider; +import com.owncloud.android.datamodel.ReceiverFlag; +import com.owncloud.android.datamodel.SyncedFolder; +import com.owncloud.android.datamodel.SyncedFolderProvider; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.datastorage.DataStorageProvider; +import com.owncloud.android.datastorage.StoragePoint; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.status.NextcloudVersion; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.lib.resources.status.OwnCloudVersion; +import com.owncloud.android.ui.activity.SyncedFoldersActivity; +import com.owncloud.android.ui.notifications.NotificationUtils; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.FilesSyncHelper; +import com.owncloud.android.utils.PermissionUtil; +import com.owncloud.android.utils.ReceiversHelper; +import com.owncloud.android.utils.SecurityUtils; +import com.owncloud.android.utils.theme.CapabilityUtils; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import org.conscrypt.Conscrypt; +import org.greenrobot.eventbus.EventBus; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Method; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.util.Pair; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.ProcessLifecycleOwner; +import dagger.android.AndroidInjector; +import dagger.android.DispatchingAndroidInjector; +import dagger.android.HasAndroidInjector; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import static com.owncloud.android.ui.activity.ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP; + + +/** + * Main Application of the project. Contains methods to build the "static" strings. These strings were before constants + * in different classes. + */ +public class MainApp extends Application implements HasAndroidInjector, NetworkChangeListener { + public static final OwnCloudVersion OUTDATED_SERVER_VERSION = NextcloudVersion.nextcloud_30; + public static final OwnCloudVersion MINIMUM_SUPPORTED_SERVER_VERSION = OwnCloudVersion.nextcloud_20; + + private static final String TAG = MainApp.class.getSimpleName(); + public static final String DOT = "."; + + private static WeakReference appContext; + + private static String storagePath; + + private static boolean mOnlyOnDevice; + private static boolean mOnlyPersonalFiles; + + + @Inject + protected AppPreferences preferences; + + @Inject + protected DispatchingAndroidInjector dispatchingAndroidInjector; + + @Inject + protected UserAccountManager accountManager; + + @Inject + protected UploadsStorageManager uploadsStorageManager; + + @Inject + protected OnboardingService onboarding; + + @Inject + ConnectivityService connectivityService; + + @Inject + SyncedFolderProvider syncedFolderProvider; + + @Inject PowerManagementService powerManagementService; + + @Inject + Logger logger; + + @Inject + AppInfo appInfo; + + @Inject + BackgroundJobManager backgroundJobManager; + + @Inject + Clock clock; + + @Inject + EventBus eventBus; + + @Inject + MigrationsManager migrationsManager; + + @Inject + InAppReviewHelper inAppReviewHelper; + + @Inject + PassCodeManager passCodeManager; + + @Inject WalledCheckCache walledCheckCache; + + @Inject ComposeProcessTextAlias composeProcessTextAlias; + + // workaround because injection is initialized on onAttachBaseContext + // and getApplicationContext is null at that point, which crashes when getting current user + @Inject Provider viewThemeUtilsProvider; + private ViewThemeUtils viewThemeUtils; + + @SuppressWarnings("unused") + private boolean mBound; + + private static AppComponent appComponent; + + private NetworkChangeReceiver networkChangeReceiver; + + /** + * Temporary hack + */ + private static void initGlobalContext(Context context) { + appContext = new WeakReference<>(context); + } + + /** + * Temporary getter replacing Dagger DI + * TODO: remove when cleaning DI in NContentObserverJob + */ + public AppPreferences getPreferences() { + return preferences; + } + + /** + * Temporary getter replacing Dagger DI + * TODO: remove when cleaning DI in NContentObserverJob + */ + public PowerManagementService getPowerManagementService() { + return powerManagementService; + } + + private void registerNetworkChangeReceiver() { + IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); + registerReceiver(networkChangeReceiver, filter); + } + + private String getAppProcessName() { + return Application.getProcessName(); + } + + @Override + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + + initGlobalContext(this); + initDagger(); + + // we don't want to handle crashes occurring inside crash reporter activity/process; + // let the platform deal with those + final boolean isCrashReportingProcess = getAppProcessName().endsWith(":crash"); + + if (!isCrashReportingProcess && !appInfo.isDebugBuild()) { + Thread.UncaughtExceptionHandler defaultPlatformHandler = Thread.getDefaultUncaughtExceptionHandler(); + + if (defaultPlatformHandler != null) { + final ExceptionHandler crashReporter = new ExceptionHandler(this, + defaultPlatformHandler); + Thread.setDefaultUncaughtExceptionHandler(crashReporter); + } + } + } + + private void initDagger() { + appComponent = DaggerAppComponent.builder() + .application(this) + .build(); + + appComponent.inject(this); + } + + /** + * USE SPARINGLY! This should only be used for injection of Theme classes in custom Views, and they need to + * be added as methods in the {@link AppComponent} itself. + *

+ * Once we adopt Hilt this won't be necessary either, as View is a supported target in Hilt. + * + * @return the {@link AppComponent} for this app + */ + public static AppComponent getAppComponent() { + if (appComponent == null) { + throw new IllegalStateException("Dagger not initialized!"); + } + return appComponent; + } + + + @SuppressFBWarnings("ST") + @Override + public void onCreate() { + enableStrictMode(); + + viewThemeUtils = viewThemeUtilsProvider.get(); + + setAppTheme(preferences.getDarkThemeMode()); + super.onCreate(); + + ProcessLifecycleOwner.get().getLifecycle().addObserver(lifecycleEventObserver); + + insertConscrypt(); + + registerActivityLifecycleCallbacks(new ActivityInjector()); + + //update the app restart count when app is launched by the user + inAppReviewHelper.resetAndIncrementAppRestartCounter(); + + int startedMigrationsCount = migrationsManager.startMigration(); + logger.i(TAG, String.format(Locale.US, "Started %d migrations", startedMigrationsCount)); + + new SecurityUtils(); + DisplayUtils.useCompatVectorIfNeeded(); + + fixStoragePath(); + + checkCancelDownloadJobs(); + + MainApp.storagePath = preferences.getStoragePath(getApplicationContext().getFilesDir().getAbsolutePath()); + + OwnCloudClientManagerFactory.setUserAgent(getUserAgent()); + + if (isClientBrandedPlus()) { + setProxyConfig(); + ContextExtensionsKt.registerBroadcastReceiver(this, restrictionsReceiver, restrictionsFilter, ReceiverFlag.NotExported); + } else { + setProxyForNonBrandedPlusClients(); + } + + // initialise thumbnails cache on background thread + ThumbnailsCacheManager.initDiskCacheAsync(); + + + if (MDMConfig.INSTANCE.isLogEnabled(this)) { + // use app writable dir, no permissions needed + Log_OC.setLoggerImplementation(new LegacyLoggerAdapter(logger)); + Log_OC.d("Debug", "start logging"); + } + + try { + Method m = StrictMode.class.getMethod("disableDeathOnFileUriExposure"); + m.invoke(null); + } catch (Exception e) { + Log_OC.d("Debug", "Failed to disable uri exposure"); + } + + Log_OC.d(TAG, "scheduleContentObserverJob, called"); + backgroundJobManager.scheduleContentObserverJob(); + + initSyncOperations(this, + preferences, + uploadsStorageManager, + accountManager, + connectivityService, + powerManagementService, + backgroundJobManager, + clock, + viewThemeUtils, + walledCheckCache); + initContactsBackup(accountManager, backgroundJobManager); + notificationChannels(); + + if (backgroundJobManager != null) { + backgroundJobManager.scheduleMediaFoldersDetectionJob(); + backgroundJobManager.startMediaFoldersDetectionJob(); + backgroundJobManager.schedulePeriodicHealthStatus(); + + if (preferences.isTwoWaySyncEnabled()) { + backgroundJobManager.scheduleInternal2WaySync(preferences.getTwoWaySyncInterval()); + } + + backgroundJobManager.startPeriodicallyOfflineOperation(); + } + + registerGlobalPassCodeProtection(); + networkChangeReceiver = new NetworkChangeReceiver(this, connectivityService); + registerNetworkChangeReceiver(); + + if (!MDMConfig.INSTANCE.sendFilesSupport(this)) { + disableDocumentsStorageProvider(); + } + } + + public void disableDocumentsStorageProvider() { + String packageName = getPackageName(); + String providerClassName = "com.owncloud.android.providers.DocumentsStorageProvider"; + ComponentName componentName = new ComponentName(packageName, providerClassName); + PackageManager packageManager = getPackageManager(); + packageManager.setComponentEnabledSetting(componentName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + } + + private final LifecycleEventObserver lifecycleEventObserver = ((lifecycleOwner, event) -> { + if (event == Lifecycle.Event.ON_START) { + Log_OC.d(TAG, "APP IN FOREGROUND"); + composeProcessTextAlias.configure(); + + if (preferences.startAutoUploadOnStart()) { + FilesSyncHelper.startAutoUploadForEnabledSyncedFolders(syncedFolderProvider, + backgroundJobManager, + false); + preferences.setLastAutoUploadOnStartTime(System.currentTimeMillis()); + } + } else if (event == Lifecycle.Event.ON_STOP) { + passCodeManager.setCanAskPin(true); + Log_OC.d(TAG, "APP IN BACKGROUND"); + } else if (event == Lifecycle.Event.ON_RESUME) { + setProxyConfig(); + Log_OC.d(TAG, "APP ON RESUME"); + } + }); + + private void setProxyForNonBrandedPlusClients() { + try { + OwnCloudClientManagerFactory.setProxyHost(getResources().getString(R.string.proxy_host)); + OwnCloudClientManagerFactory.setProxyPort(getResources().getInteger(R.integer.proxy_port)); + } catch (Resources.NotFoundException e) { + Log_OC.d(TAG, "Error caught at setProxyForNonBrandedPlusClients: " + e); + } + } + + public static boolean isClientBranded() { + return getAppContext().getResources().getBoolean(R.bool.is_branded_client); + } + + public static boolean isClientBrandedPlus() { + return getAppContext().getResources().getBoolean(R.bool.is_branded_plus_client); + } + + private final IntentFilter restrictionsFilter = new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED); + + private final BroadcastReceiver restrictionsReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + setProxyConfig(); + } + }; + + private void setProxyConfig() { + if (!isClientBrandedPlus()) { + Log_OC.d(TAG, "Proxy configuration cannot be set. Client is not branded plus."); + return; + } + + String host = MDMConfig.INSTANCE.getHost(this); + int port = MDMConfig.INSTANCE.getPort(this); + + if (TextUtils.isEmpty(host) || port == -1) { + Log_OC.d(TAG, "Proxy configuration cannot be found"); + return; + } + + try { + OwnCloudClientManagerFactory.setProxyHost(host); + OwnCloudClientManagerFactory.setProxyPort(port); + + Log_OC.d(TAG, "Proxy configuration successfully set"); + } catch (Resources.NotFoundException e) { + Log_OC.e(TAG, "Proxy config cannot able to set due to: $e"); + } + } + + private void registerGlobalPassCodeProtection() { + registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() { + + @Override + public void onActivityCreated(@NonNull Activity activity, Bundle savedInstanceState) { + Log_OC.d(activity.getClass().getSimpleName(), "onCreate(Bundle) starting"); + onboarding.launchActivityIfNeeded(activity); + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + Log_OC.d(activity.getClass().getSimpleName(), "onStart() starting"); + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + Log_OC.d(activity.getClass().getSimpleName(), "onResume() starting"); + // we are checking activity is not launcher activity because there is timer in launcher + // which will reopen the passcode screen + if (!(activity instanceof LauncherActivity)) { + passCodeManager.onActivityResumed(activity); + } + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + Log_OC.d(activity.getClass().getSimpleName(), "onPause() ending"); + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + Log_OC.d(activity.getClass().getSimpleName(), "onStop() ending"); + // since we are not showing passcode on launch activity + // so we don't need to call the stopped method as well + if (!(activity instanceof LauncherActivity)) { + passCodeManager.onActivityStopped(activity); + } + } + + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) { + Log_OC.d(activity.getClass().getSimpleName(), "onSaveInstanceState(Bundle) starting"); + } + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + Log_OC.d(activity.getClass().getSimpleName(), "onDestroy() ending"); + } + }); + } + + public static void initContactsBackup(UserAccountManager accountManager, BackgroundJobManager backgroundJobManager) { + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(appContext.get()); + if (accountManager == null) { + return; + } + + List users = accountManager.getAllUsers(); + for (User user : users) { + if (backgroundJobManager != null && arbitraryDataProvider.getBooleanValue(user, PREFERENCE_CONTACTS_AUTOMATIC_BACKUP)) { + backgroundJobManager.schedulePeriodicContactsBackup(user); + } + } + } + + private void insertConscrypt() { + Security.insertProviderAt(Conscrypt.newProvider(), 1); + + try { + Conscrypt.Version version = Conscrypt.version(); + Log_OC.i(TAG, "Using Conscrypt/" + + version.major() + + DOT + + version.minor() + + DOT + version.patch() + + " for TLS"); + SSLEngine engine = SSLContext.getDefault().createSSLEngine(); + Log_OC.i(TAG, "Enabled protocols: " + Arrays.toString(engine.getEnabledProtocols()) + " }"); + Log_OC.i(TAG, "Enabled ciphers: " + Arrays.toString(engine.getEnabledCipherSuites()) + " }"); + } catch (NoSuchAlgorithmException e) { + Log_OC.e(TAG, e.getMessage()); + } + } + + @SuppressLint("ApplySharedPref") // commit is done on purpose to write immediately + private void fixStoragePath() { + if (!preferences.isStoragePathFixEnabled()) { + StoragePoint[] storagePoints = DataStorageProvider.getInstance().getAvailableStoragePoints(); + String storagePath = preferences.getStoragePath(""); + + if (TextUtils.isEmpty(storagePath)) { + if (preferences.getLastSeenVersionCode() != 0) { + // We already used the app, but no storage is set - fix that! + preferences.setStoragePath(Environment.getExternalStorageDirectory().getAbsolutePath()); + preferences.removeKeysMigrationPreference(); + } else { + // find internal storage path that's indexable + boolean set = false; + for (StoragePoint storagePoint : storagePoints) { + if (storagePoint.getStorageType() == StoragePoint.StorageType.INTERNAL && + storagePoint.getPrivacyType() == StoragePoint.PrivacyType.PUBLIC) { + preferences.setStoragePath(storagePoint.getPath()); + preferences.removeKeysMigrationPreference(); + set = true; + break; + } + } + + if (!set) { + for (StoragePoint storagePoint : storagePoints) { + if (storagePoint.getPrivacyType() == StoragePoint.PrivacyType.PUBLIC) { + preferences.setStoragePath(storagePoint.getPath()); + preferences.removeKeysMigrationPreference(); + break; + } + } + + } + } + preferences.setStoragePathFixEnabled(true); + } else { + preferences.removeKeysMigrationPreference(); + preferences.setStoragePathFixEnabled(true); + } + } + } + + private void enableStrictMode() { + if (BuildConfig.DEBUG && BuildConfig.RUNTIME_PERF_ANALYSIS) { + Log_OC.d(TAG, "Enabling StrictMode"); + StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectAll() + .penaltyLog() + .build()); + StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() + .build()); + } + } + + private void checkCancelDownloadJobs() { + if (backgroundJobManager != null && preferences.shouldStopDownloadJobsOnStart()) { + backgroundJobManager.cancelAllFilesDownloadJobs(); + preferences.setStopDownloadJobsOnStart(false); + } + } + + public static void initSyncOperations( + final Context context, + final AppPreferences preferences, + final UploadsStorageManager uploadsStorageManager, + final UserAccountManager accountManager, + final ConnectivityService connectivityService, + final PowerManagementService powerManagementService, + final BackgroundJobManager backgroundJobManager, + final Clock clock, + final ViewThemeUtils viewThemeUtils, + final WalledCheckCache walledCheckCache) { + updateToAutoUpload(context); + cleanOldEntries(clock); + updateAutoUploadEntries(clock); + + if (getAppContext() != null) { + if (PermissionUtil.checkStoragePermission(getAppContext())) { + splitOutAutoUploadEntries(clock, viewThemeUtils); + } else { + preferences.setAutoUploadSplitEntriesEnabled(true); + } + } + + if (!preferences.isAutoUploadInitialized()) { + preferences.setAutoUploadInit(true); + } + + FilesSyncHelper.restartUploadsIfNeeded( + uploadsStorageManager, + accountManager, + connectivityService, + powerManagementService); + + backgroundJobManager.scheduleOfflineSync(); + + ReceiversHelper.registerNetworkChangeReceiver(uploadsStorageManager, + accountManager, + connectivityService, + powerManagementService, + walledCheckCache); + + ReceiversHelper.registerPowerChangeReceiver(uploadsStorageManager, + accountManager, + connectivityService, + powerManagementService); + + ReceiversHelper.registerPowerSaveReceiver(uploadsStorageManager, + accountManager, + connectivityService, + powerManagementService); + } + + public static void notificationChannels() { + if (getAppContext() != null) { + Context context = getAppContext(); + NotificationManager notificationManager = (NotificationManager) + context.getSystemService(Context.NOTIFICATION_SERVICE); + + if (notificationManager != null) { + createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD, + R.string.notification_channel_download_name_short, + R.string.notification_channel_download_description, context); + + createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD, + R.string.notification_channel_upload_name_short, + R.string.notification_channel_upload_description, context, NotificationManager.IMPORTANCE_LOW); + + createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_MEDIA, + R.string.notification_channel_media_name, + R.string.notification_channel_media_description, context); + + createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_FILE_SYNC, + R.string.notification_channel_file_sync_name, + R.string.notification_channel_file_sync_description, context); + + notificationManager.deleteNotificationChannel(NotificationUtils.NOTIFICATION_CHANNEL_FILE_OBSERVER); + + createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_PUSH, + R.string.notification_channel_push_name, R.string + .notification_channel_push_description, context, NotificationManager.IMPORTANCE_DEFAULT); + + createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS, + R.string.notification_channel_background_operations_name, R.string + .notification_channel_background_operations_description, context, NotificationManager.IMPORTANCE_LOW); + + createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_GENERAL, R.string + .notification_channel_general_name, R.string.notification_channel_general_description, + context, NotificationManager.IMPORTANCE_DEFAULT); + + createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS, + R.string.notification_channel_offline_operations_name_short, + R.string.notification_channel_offline_operations_description, context); + + createChannel(notificationManager, + NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER, + R.string.notification_channel_content_observer_name_short, + R.string.notification_channel_content_observer_description, + context, + NotificationManager.IMPORTANCE_LOW); + } else { + Log_OC.e(TAG, "Notification manager is null"); + } + } + } + + private static void createChannel(NotificationManager notificationManager, + String channelId, int channelName, + int channelDescription, Context context) { + createChannel(notificationManager, channelId, channelName, channelDescription, context, + NotificationManager.IMPORTANCE_LOW); + } + + private static void createChannel(NotificationManager notificationManager, + String channelId, int channelName, + int channelDescription, Context context, int importance) { + if (getAppContext() != null) { + CharSequence name = context.getString(channelName); + String description = context.getString(channelDescription); + NotificationChannel channel = new NotificationChannel(channelId, name, importance); + + channel.setDescription(description); + channel.enableLights(false); + channel.enableVibration(false); + notificationManager.createNotificationChannel(channel); + } + } + + public static String string(int id) { + return getAppContext().getString(id); + } + + public static String string(int id, Object args) { + return getAppContext().getString(id, args); + } + + public static Context getAppContext() { + return MainApp.appContext.get(); + } + + public static void setAppContext(Context context) { + MainApp.appContext = new WeakReference<>(context); + } + + public static String getStoragePath() { + return MainApp.storagePath; + } + + public static void setStoragePath(String path) { + MainApp.storagePath = path; + } + + // Methods to obtain Strings referring app_name + // From AccountAuthenticator + // public static final String ACCOUNT_TYPE = "owncloud"; + public static String getAccountType(Context context) { + return context.getResources().getString(R.string.account_type); + } + + // From AccountAuthenticator + // public static final String AUTHORITY = "org.owncloud"; + public static String getAuthority() { + return string(R.string.authority); + } + + // From AccountAuthenticator + // public static final String AUTH_TOKEN_TYPE = "org.owncloud"; + public static String getAuthTokenType() { + return string(R.string.authority); + } + + // From ProviderMeta + // public static final String DB_FILE = "owncloud.db"; + public static String getDBFile() { + return string(R.string.db_file); + } + + // From ProviderMeta + // private final String mDatabaseName = "ownCloud"; + public static String getDBName() { + return string(R.string.db_name); + } + + /** + * name of data_folder, e.g., "owncloud" + */ + public static String getDataFolder() { + return string(R.string.data_folder); + } + + public static void showOnlyFilesOnDevice(boolean state) { + mOnlyOnDevice = state; + } + + public static void showOnlyPersonalFiles(boolean state) { + mOnlyPersonalFiles = state; + } + + public static boolean isOnlyOnDevice() { + return mOnlyOnDevice; + } + + public static boolean isOnlyPersonFiles() { + return mOnlyPersonalFiles; + } + + public static Integer getMenuItemId() { + if (MainApp.isOnlyPersonFiles()) { + return R.id.nav_personal_files; + } + + if (MainApp.isOnlyOnDevice()) { + return R.id.nav_on_device; + } + + return null; + } + + public static String getUserAgent() { + // Mozilla/5.0 (Android) Nextcloud-android/2.1.0 + return getUserAgent(R.string.nextcloud_user_agent); + } + + public static void showMessage(int messageId) { + ContextExtensionsKt.showToast(getAppContext(), messageId); + } + + // user agent + private static String getUserAgent(@StringRes int agent) { + String appString = string(agent); + String brandedName = string(R.string.name_for_branded_user_agent); + String packageName = getAppContext().getPackageName(); + String version = ""; + + try { + PackageInfo pInfo = getAppContext().getPackageManager().getPackageInfo(packageName, 0); + if (pInfo != null) { + version = pInfo.versionName; + } + } catch (PackageManager.NameNotFoundException e) { + Log_OC.e(TAG, "Trying to get packageName", e.getCause()); + } + + return String.format(appString, version, brandedName); + } + + private static void updateToAutoUpload(Context context) { + AppPreferences preferences = AppPreferencesImpl.fromContext(context); + if (preferences.instantPictureUploadEnabled() || preferences.instantVideoUploadEnabled()) { + preferences.removeLegacyPreferences(); + + // show info pop-up + try { + showAutoUploadAlertDialog(context); + } catch (WindowManager.BadTokenException e) { + Log_OC.i(TAG, "Error showing Auto Upload Update dialog, so skipping it: " + e.getMessage()); + } + } + } + + + private static void showAutoUploadAlertDialog(Context context) { + new MaterialAlertDialogBuilder(context, R.style.Theme_ownCloud_Dialog) + .setTitle(R.string.drawer_synced_folders) + .setMessage(R.string.synced_folders_new_info) + .setPositiveButton(R.string.drawer_open, (dialog, which) -> { + Intent folderSyncIntent = new Intent(context, SyncedFoldersActivity.class); + dialog.dismiss(); + context.startActivity(folderSyncIntent); + }) + .setNegativeButton(R.string.drawer_close, (dialog, which) -> dialog.dismiss()) + .setIcon(R.drawable.nav_synced_folders) + .create() + .show(); + } + + private static void updateAutoUploadEntries(Clock clock) { + // updates entries to reflect their true paths + Context context = getAppContext(); + AppPreferences preferences = AppPreferencesImpl.fromContext(context); + if (!preferences.isAutoUploadPathsUpdateEnabled()) { + SyncedFolderProvider syncedFolderProvider = + new SyncedFolderProvider(MainApp.getAppContext().getContentResolver(), preferences, clock); + syncedFolderProvider.updateAutoUploadPaths(appContext.get()); + } + } + + private static void splitOutAutoUploadEntries(Clock clock, + final ViewThemeUtils viewThemeUtils) { + Context context = getAppContext(); + AppPreferences preferences = AppPreferencesImpl.fromContext(context); + if (!preferences.isAutoUploadSplitEntriesEnabled()) { + // magic to split out existing synced folders in two when needed + // otherwise, we migrate them to their proper type (image or video) + Log_OC.i(TAG, "Migrate synced_folders records for image/video split"); + ContentResolver contentResolver = context.getContentResolver(); + + SyncedFolderProvider syncedFolderProvider = new SyncedFolderProvider(contentResolver, preferences, clock); + + final List imageMediaFolders = MediaProvider.getImageFolders(contentResolver, + 1, + null, + true); + final List videoMediaFolders = MediaProvider.getVideoFolders(contentResolver, + 1, + null, + true); + + ArrayList idsToDelete = new ArrayList<>(); + List syncedFolders = syncedFolderProvider.getSyncedFolders(); + long primaryKey; + SyncedFolder newSyncedFolder; + for (SyncedFolder syncedFolder : syncedFolders) { + idsToDelete.add(syncedFolder.getId()); + Log_OC.i(TAG, "Migration check for synced_folders record: " + + syncedFolder.getId() + " - " + syncedFolder.getLocalPath()); + + for (MediaFolder imageMediaFolder : imageMediaFolders) { + String absolutePathOfImageFolder = imageMediaFolder.absolutePath; + + if (absolutePathOfImageFolder != null) { + if (absolutePathOfImageFolder.equals(syncedFolder.getLocalPath())) { + newSyncedFolder = (SyncedFolder) syncedFolder.clone(); + newSyncedFolder.setType(MediaFolderType.IMAGE); + primaryKey = syncedFolderProvider.storeSyncedFolder(newSyncedFolder); + Log_OC.i(TAG, "Migrated image synced_folders record: " + + primaryKey + " - " + newSyncedFolder.getLocalPath()); + break; + } + } + } + + for (MediaFolder videoMediaFolder : videoMediaFolders) { + String absolutePathOfVideoFolder = videoMediaFolder.absolutePath; + + if (absolutePathOfVideoFolder != null) { + if (absolutePathOfVideoFolder.equals(syncedFolder.getLocalPath())) { + newSyncedFolder = (SyncedFolder) syncedFolder.clone(); + newSyncedFolder.setType(MediaFolderType.VIDEO); + primaryKey = syncedFolderProvider.storeSyncedFolder(newSyncedFolder); + Log_OC.i(TAG, "Migrated video synced_folders record: " + + primaryKey + " - " + newSyncedFolder.getLocalPath()); + break; + } + } + + } + } + + for (long id : idsToDelete) { + Log_OC.i(TAG, "Removing legacy synced_folders record: " + id); + syncedFolderProvider.deleteSyncedFolder(id); + } + + preferences.setAutoUploadSplitEntriesEnabled(true); + } + } + + private static void cleanOldEntries(Clock clock) { + // previous versions of application created broken entries in the SyncedFolderProvider + // database, and this cleans all that and leaves 1 (newest) entry per synced folder + + Context context = getAppContext(); + AppPreferences preferences = AppPreferencesImpl.fromContext(context); + + if (!preferences.isLegacyClean()) { + SyncedFolderProvider syncedFolderProvider = + new SyncedFolderProvider(context.getContentResolver(), preferences, clock); + + List syncedFolderList = syncedFolderProvider.getSyncedFolders(); + Map, Long> syncedFolders = new HashMap<>(); + for (SyncedFolder syncedFolder : syncedFolderList) { + Pair checkPair = new Pair<>(syncedFolder.getAccount(), syncedFolder.getLocalPath()); + if (syncedFolders.containsKey(checkPair)) { + Long folderId = syncedFolders.get(checkPair); + + if (folderId != null) { + if (syncedFolder.getId() > folderId) { + syncedFolders.put(checkPair, syncedFolder.getId()); + } + } + } else { + syncedFolders.put(checkPair, syncedFolder.getId()); + } + } + + ArrayList ids = new ArrayList<>(syncedFolders.values()); + + if (ids.size() > 0) { + int deletedCount = syncedFolderProvider.deleteSyncedFoldersNotInList(ids); + if (deletedCount > 0) { + preferences.setLegacyClean(true); + } + } else { + preferences.setLegacyClean(true); + } + } + } + + @Override + public AndroidInjector androidInjector() { + return dispatchingAndroidInjector; + } + + public static void setAppTheme(DarkMode mode) { + switch (mode) { + case LIGHT -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + case DARK -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + case SYSTEM -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } + } + + @Override + public void networkAndServerConnectionListener(boolean isNetworkAndServerAvailable) { + if (backgroundJobManager == null) { + Log_OC.d(TAG, "Offline operations terminated, backgroundJobManager cannot be null"); + return; + } + + if (isNetworkAndServerAvailable) { + backgroundJobManager.startOfflineOperations(); + } + } + + @Override + public void onTerminate() { + super.onTerminate(); + ReceiversHelper.shutdown(); + } +} diff --git a/src/main/java/com/owncloud/android/authentication/AccountAuthenticator.java b/app/src/main/java/com/owncloud/android/authentication/AccountAuthenticator.java similarity index 79% rename from src/main/java/com/owncloud/android/authentication/AccountAuthenticator.java rename to app/src/main/java/com/owncloud/android/authentication/AccountAuthenticator.java index 8bcee0be43dd..6f3adf6a3aac 100644 --- a/src/main/java/com/owncloud/android/authentication/AccountAuthenticator.java +++ b/app/src/main/java/com/owncloud/android/authentication/AccountAuthenticator.java @@ -1,24 +1,13 @@ -/** - * ownCloud Android client application - * - * @author David A. Velasco - * Copyright (C) 2012 Bartek Przybylski - * Copyright (C) 2015 ownCloud Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . +/* + * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-FileCopyrightText: 2011-2012 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ - package com.owncloud.android.authentication; import android.accounts.AbstractAccountAuthenticator; @@ -32,23 +21,21 @@ import android.os.Handler; import android.widget.Toast; +import com.nextcloud.utils.mdm.MDMConfig; import com.owncloud.android.MainApp; import com.owncloud.android.R; import com.owncloud.android.lib.common.accounts.AccountTypeUtils; import com.owncloud.android.lib.common.utils.Log_OC; - /** * Authenticator for ownCloud accounts. - * * Controller class accessed from the system AccountManager, * providing integration of ownCloud accounts with the Android system. - * * TODO - better separation in operations for OAuth-capable and regular ownCloud accounts. * TODO - review completeness */ public class AccountAuthenticator extends AbstractAccountAuthenticator { - + /** * Is used by android system to assign accounts to authenticators. * Should be used by application and all extensions. @@ -57,11 +44,11 @@ public class AccountAuthenticator extends AbstractAccountAuthenticator { public static final String KEY_REQUIRED_FEATURES = "requiredFeatures"; public static final String KEY_LOGIN_OPTIONS = "loginOptions"; public static final String KEY_ACCOUNT = "account"; - + private static final String TAG = AccountAuthenticator.class.getSimpleName(); - + private Context mContext; - + private Handler mHandler; public AccountAuthenticator(Context context) { @@ -75,17 +62,16 @@ public AccountAuthenticator(Context context) { */ @Override public Bundle addAccount(AccountAuthenticatorResponse response, - String accountType, String authTokenType, - String[] requiredFeatures, Bundle options) - throws NetworkErrorException { + String accountType, String authTokenType, + String[] requiredFeatures, Bundle options) { Log_OC.i(TAG, "Adding account with type " + accountType + " and auth token " + authTokenType); - - final Bundle bundle = new Bundle(); - + AccountManager accountManager = AccountManager.get(mContext); - Account[] accounts = accountManager.getAccountsByType(MainApp.getAccountType()); - - if (mContext.getResources().getBoolean(R.bool.multiaccount_support) || accounts.length < 1) { + Account[] accounts = accountManager.getAccountsByType(MainApp.getAccountType(mContext)); + + final Bundle bundle = new Bundle(); + + if (accounts.length < 1 || MDMConfig.INSTANCE.multiAccountSupport(mContext)) { try { validateAccountType(accountType); } catch (AuthenticatorException e) { @@ -102,26 +88,17 @@ public Bundle addAccount(AccountAuthenticatorResponse response, intent.putExtra(AuthenticatorActivity.EXTRA_ACTION, AuthenticatorActivity.ACTION_CREATE); setIntentFlags(intent); - + bundle.putParcelable(AccountManager.KEY_INTENT, intent); - } else { - // Return an error bundle.putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION); - final String message = String.format(mContext.getString(R.string.auth_unsupported_multiaccount), mContext.getString(R.string.app_name)); + final String message = String.format(mContext.getString(R.string.auth_unsupported_multiaccount), mContext.getString(R.string.app_name)); bundle.putString(AccountManager.KEY_ERROR_MESSAGE, message); - - mHandler.post(new Runnable() { - - @Override - public void run() { - Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show(); - } - }); - + + mHandler.post(() -> Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()); } - + return bundle; } @@ -130,7 +107,7 @@ public void run() { */ @Override public Bundle confirmCredentials(AccountAuthenticatorResponse response, - Account account, Bundle options) throws NetworkErrorException { + Account account, Bundle options) { try { validateAccountType(account.type); } catch (AuthenticatorException e) { @@ -161,9 +138,8 @@ public Bundle editProperties(AccountAuthenticatorResponse response, String accou */ @Override public Bundle getAuthToken(AccountAuthenticatorResponse response, - Account account, String authTokenType, Bundle options) - throws NetworkErrorException { - /// validate parameters + Account account, String authTokenType, Bundle options) { + // validate parameters try { validateAccountType(account.type); validateAuthTokenType(authTokenType); @@ -171,11 +147,11 @@ public Bundle getAuthToken(AccountAuthenticatorResponse response, Log_OC.e(TAG, "Failed to validate account type " + account.type + ": " + e.getMessage(), e); return e.getFailureBundle(); } - + /// check if required token is stored final AccountManager am = AccountManager.get(mContext); String accessToken; - if (authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(MainApp.getAccountType()))) { + if (authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(MainApp.getAccountType(mContext)))) { accessToken = am.getPassword(account); } else { accessToken = am.peekAuthToken(account, authTokenType); @@ -183,11 +159,11 @@ public Bundle getAuthToken(AccountAuthenticatorResponse response, if (accessToken != null) { final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); - result.putString(AccountManager.KEY_ACCOUNT_TYPE, MainApp.getAccountType()); + result.putString(AccountManager.KEY_ACCOUNT_TYPE, MainApp.getAccountType(mContext)); result.putString(AccountManager.KEY_AUTHTOKEN, accessToken); return result; } - + /// if not stored, return Intent to access the AuthenticatorActivity and UPDATE the token for the account Intent intent = new Intent(mContext, AuthenticatorActivity.class); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); @@ -195,7 +171,7 @@ public Bundle getAuthToken(AccountAuthenticatorResponse response, intent.putExtra(KEY_LOGIN_OPTIONS, options); intent.putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, account); intent.putExtra(AuthenticatorActivity.EXTRA_ACTION, AuthenticatorActivity.ACTION_UPDATE_EXPIRED_TOKEN); - + final Bundle bundle = new Bundle(); bundle.putParcelable(AccountManager.KEY_INTENT, intent); @@ -209,7 +185,7 @@ public String getAuthTokenLabel(String authTokenType) { @Override public Bundle hasFeatures(AccountAuthenticatorResponse response, - Account account, String[] features) throws NetworkErrorException { + Account account, String[] features) { final Bundle result = new Bundle(); result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true); return result; @@ -217,8 +193,7 @@ public Bundle hasFeatures(AccountAuthenticatorResponse response, @Override public Bundle updateCredentials(AccountAuthenticatorResponse response, - Account account, String authTokenType, Bundle options) - throws NetworkErrorException { + Account account, String authTokenType, Bundle options) { Intent intent = new Intent(mContext, AuthenticatorActivity.class); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); @@ -245,17 +220,16 @@ private void setIntentFlags(Intent intent) { } private void validateAccountType(String type) throws UnsupportedAccountTypeException { - if (!type.equals(MainApp.getAccountType())) { + if (!type.equals(MainApp.getAccountType(mContext))) { throw new UnsupportedAccountTypeException(); } } private void validateAuthTokenType(String authTokenType) throws UnsupportedAuthTokenTypeException { - if (!authTokenType.equals(MainApp.getAuthTokenType()) && - !authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(MainApp.getAccountType())) && - !authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.getAccountType())) && - !authTokenType.equals(AccountTypeUtils.getAuthTokenTypeRefreshToken(MainApp.getAccountType())) && - !authTokenType.equals(AccountTypeUtils.getAuthTokenTypeSamlSessionCookie(MainApp.getAccountType()))) { + String accountType = MainApp.getAccountType(mContext); + + if (!authTokenType.equals(accountType) && + !authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(accountType))) { throw new UnsupportedAuthTokenTypeException(); } } diff --git a/app/src/main/java/com/owncloud/android/authentication/AccountAuthenticatorActivity.java b/app/src/main/java/com/owncloud/android/authentication/AccountAuthenticatorActivity.java new file mode 100644 index 000000000000..bf90cf0784c7 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/AccountAuthenticatorActivity.java @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2009 The Android Open Source Project + * SPDX-License-Identifier: Apache-2.0 + */ +package com.owncloud.android.authentication; + +import android.accounts.AccountAuthenticatorResponse; +import android.accounts.AccountManager; +import android.os.Bundle; + +import com.nextcloud.utils.extensions.IntentExtensionsKt; + +import androidx.appcompat.app.AppCompatActivity; + +/* + * Base class for implementing an Activity that is used to help implement an AbstractAccountAuthenticator. + * If the AbstractAccountAuthenticator needs to use an activity to handle the request then it can have the activity extend + * AccountAuthenticatorActivity. The AbstractAccountAuthenticator passes in the response to the intent using the following: + * intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); + * + * The activity then sets the result that is to be handed to the response via setAccountAuthenticatorResult(android.os.Bundle). + * This result will be sent as the result of the request when the activity finishes. If this is never set or if it is set to null + * then error AccountManager.ERROR_CODE_CANCELED will be called on the response. + */ +public abstract class AccountAuthenticatorActivity extends AppCompatActivity { + + private AccountAuthenticatorResponse mAccountAuthenticatorResponse; + private Bundle mResultBundle; + + /** + * Set the result that is to be sent as the result of the request that caused this Activity to be launched. + * If result is null or this method is never called then the request will be canceled. + * + * @param result this is returned as the result of the AbstractAccountAuthenticator request + */ + public final void setAccountAuthenticatorResult(Bundle result) { + mResultBundle = result; + } + + /** + * Retrieves the AccountAuthenticatorResponse from either the intent of the icicle, if the + * icicle is non-zero. + * @param savedInstanceState the save instance data of this Activity, may be null + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mAccountAuthenticatorResponse = + IntentExtensionsKt.getParcelableArgument(getIntent(), + AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, + AccountAuthenticatorResponse.class); + + if (mAccountAuthenticatorResponse != null) { + mAccountAuthenticatorResponse.onRequestContinued(); + } + } + + /** + * Sends the result or a Constants.ERROR_CODE_CANCELED error if a result isn't present. + */ + @Override + public void finish() { + if (mAccountAuthenticatorResponse != null) { + // send the result bundle back if set, otherwise send an error. + if (mResultBundle != null) { + mAccountAuthenticatorResponse.onResult(mResultBundle); + } else { + mAccountAuthenticatorResponse.onError(AccountManager.ERROR_CODE_CANCELED, + "canceled"); + } + mAccountAuthenticatorResponse = null; + } + super.finish(); + } +} diff --git a/app/src/main/java/com/owncloud/android/authentication/AccountAuthenticatorService.java b/app/src/main/java/com/owncloud/android/authentication/AccountAuthenticatorService.java new file mode 100644 index 000000000000..fe50573b0fb7 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/AccountAuthenticatorService.java @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2011 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only + */ +package com.owncloud.android.authentication; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +public class AccountAuthenticatorService extends Service { + + private AccountAuthenticator mAuthenticator; + + @Override + public void onCreate() { + super.onCreate(); + mAuthenticator = new AccountAuthenticator(this); + } + + @Override + public IBinder onBind(Intent intent) { + return mAuthenticator.getIBinder(); + } + +} diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthObject.kt b/app/src/main/java/com/owncloud/android/authentication/AuthObject.kt new file mode 100644 index 000000000000..eddaecc42ac4 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/AuthObject.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.authentication + +data class AuthObject(val poll: Poll, val login: String) + +data class Poll(val token: String, val endpoint: String) diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java new file mode 100644 index 000000000000..3add5f5eef73 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java @@ -0,0 +1,1778 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023-2025 TSI-mc + * SPDX-FileCopyrightText: 2019-2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2013-2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2013-2015 David A. Velasco + * SPDX-FileCopyrightText: 2011-2012 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.authentication; + +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.util.Pair; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.webkit.CookieManager; +import android.webkit.URLUtil; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.TextView.OnEditorActionListener; + +import com.blikoon.qrcodescanner.QrCodeActivity; +import com.google.android.material.button.MaterialButton; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; +import com.nextcloud.android.common.ui.color.ColorUtil; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.nextcloud.android.lib.resources.users.GenerateOneTimeAppPasswordRemoteOperation; +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.device.DeviceInfo; +import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.network.ClientFactory; +import com.nextcloud.client.onboarding.FirstRunActivity; +import com.nextcloud.client.onboarding.OnboardingService; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.common.PlainClient; +import com.nextcloud.operations.PostMethod; +import com.nextcloud.utils.extensions.BundleExtensionsKt; +import com.nextcloud.utils.mdm.MDMConfig; +import com.owncloud.android.BuildConfig; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.databinding.AccountSetupBinding; +import com.owncloud.android.databinding.AccountSetupWebviewBinding; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.OwnCloudCredentials; +import com.owncloud.android.lib.common.OwnCloudCredentialsFactory; +import com.owncloud.android.lib.common.UserInfo; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException; +import com.owncloud.android.lib.common.accounts.AccountUtils.Constants; +import com.owncloud.android.lib.common.network.CertificateCombinedException; +import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.status.OwnCloudVersion; +import com.owncloud.android.lib.resources.users.GetUserInfoRemoteOperation; +import com.owncloud.android.operations.DetectAuthenticationMethodOperation.AuthenticationMethod; +import com.owncloud.android.operations.GetCapabilitiesOperation; +import com.owncloud.android.operations.GetServerInfoOperation; +import com.owncloud.android.providers.DocumentsStorageProvider; +import com.owncloud.android.services.OperationsService; +import com.owncloud.android.services.OperationsService.OperationsServiceBinder; +import com.owncloud.android.ui.NextcloudWebViewClient; +import com.owncloud.android.ui.activity.FileDisplayActivity; +import com.owncloud.android.ui.activity.SettingsActivity; +import com.owncloud.android.ui.dialog.IndeterminateProgressDialog; +import com.owncloud.android.ui.dialog.SslUntrustedCertDialog; +import com.owncloud.android.ui.dialog.SslUntrustedCertDialog.OnSslUntrustedCertListener; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.ErrorMessageAdapter; +import com.owncloud.android.utils.PermissionUtil; +import com.owncloud.android.utils.WebViewUtil; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.io.InputStream; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.app.ActionBar; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.ProcessLifecycleOwner; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import okhttp3.Credentials; +import okhttp3.FormBody; +import okhttp3.RequestBody; + +import static com.owncloud.android.utils.PermissionUtil.PERMISSIONS_CAMERA; + +/** + * This Activity is used to add an ownCloud account to the App + */ +public class AuthenticatorActivity extends AccountAuthenticatorActivity + implements OnRemoteOperationListener, OnEditorActionListener, OnSslUntrustedCertListener, + AuthenticatorAsyncTask.OnAuthenticatorTaskListener, Injectable { + + private static final String TAG = AuthenticatorActivity.class.getSimpleName(); + + public static final String EXTRA_ACTION = "ACTION"; + public static final String EXTRA_ACCOUNT = "ACCOUNT"; + public static final String EXTRA_USE_PROVIDER_AS_WEBLOGIN = "USE_PROVIDER_AS_WEBLOGIN"; + + private static final String KEY_HOST_URL_TEXT = "HOST_URL_TEXT"; + private static final String KEY_OC_VERSION = "OC_VERSION"; + private static final String KEY_SERVER_STATUS_TEXT = "SERVER_STATUS_TEXT"; + private static final String KEY_SERVER_STATUS_ICON = "SERVER_STATUS_ICON"; + private static final String KEY_IS_SSL_CONN = "IS_SSL_CONN"; + private static final String KEY_AUTH_STATUS_TEXT = "AUTH_STATUS_TEXT"; + private static final String KEY_AUTH_STATUS_ICON = "AUTH_STATUS_ICON"; + private static final String KEY_SERVER_AUTH_METHOD = "SERVER_AUTH_METHOD"; + private static final String KEY_WAITING_FOR_OP_ID = "WAITING_FOR_OP_ID"; + private static final String KEY_ONLY_ADD = "onlyAdd"; + + public static final byte ACTION_CREATE = 0; + public static final byte ACTION_UPDATE_EXPIRED_TOKEN = 2; // detected by the app + + public static final String UNTRUSTED_CERT_DIALOG_TAG = "UNTRUSTED_CERT_DIALOG"; + private static final String WAIT_DIALOG_TAG = "WAIT_DIALOG"; + private static final String KEY_AUTH_IS_FIRST_ATTEMPT_TAG = "KEY_AUTH_IS_FIRST_ATTEMPT"; + + private static final String KEY_USERNAME = "USERNAME"; + private static final String KEY_PASSWORD = "PASSWORD"; + private static final String KEY_ASYNC_TASK_IN_PROGRESS = "AUTH_IN_PROGRESS"; + + public static final String WEB_LOGIN = "/index.php/login/v2"; + + public static final String PROTOCOL_SUFFIX = "://"; + public static final String LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":"; + public static final String HTTPS_PROTOCOL = "https://"; + public static final String HTTP_PROTOCOL = "http://"; + + public static final int NO_ICON = 0; + public static final String EMPTY_STRING = ""; + public static final int REQUEST_CODE_FIRST_RUN = 102; + + /// parameters from EXTRAs in starter Intent + private byte mAction; + private Account mAccount; + + /// activity-level references / state + private final Handler mHandler = new Handler(); + private ServiceConnection mOperationsServiceConnection; + private OperationsServiceBinder mOperationsServiceBinder; + private AccountManager mAccountMgr; + + /// Server PRE-Fragment elements + private AccountSetupBinding accountSetupBinding = null; + private AccountSetupWebviewBinding accountSetupWebviewBinding; + + private String mServerStatusText = EMPTY_STRING; + private int mServerStatusIcon; + + private GetServerInfoOperation.ServerInfo mServerInfo = new GetServerInfoOperation.ServerInfo(); + + /// Authentication PRE-Fragment elements + private String mAuthStatusText = EMPTY_STRING; + private int mAuthStatusIcon; + + private AuthenticatorAsyncTask mAsyncTask; + + private boolean mIsFirstAuthAttempt; + + /// Identifier of operation in progress which result shouldn't be lost + private long mWaitingForOpId = Long.MAX_VALUE; + + private boolean showWebViewLoginUrl; + private String webViewUser; + private String webViewPassword; + + @Inject UserAccountManager accountManager; + @Inject AppPreferences preferences; + @Inject OnboardingService onboarding; + @Inject DeviceInfo deviceInfo; + @Inject PassCodeManager passCodeManager; + @Inject ViewThemeUtils.Factory viewThemeUtilsFactory; + @Inject ColorUtil colorUtil; + @Inject ClientFactory clientFactory; + + private AuthObject authObject = null; + private String fallbackToken; + private boolean onlyAdd = false; + + private final Gson gson = new Gson(); + + private ViewThemeUtils viewThemeUtils; + private final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); + + @VisibleForTesting + public AccountSetupBinding getAccountSetupBinding() { + return accountSetupBinding; + } + + /** + * {@inheritDoc} + *

+ * IMPORTANT ENTRY POINT 1: activity is shown to the user + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewThemeUtils = viewThemeUtilsFactory.withPrimaryAsBackground(); + viewThemeUtils.platform.colorStatusBar(this, getResources().getColor(R.color.primary)); + + Uri data = getIntent().getData(); + boolean directLogin = data != null && data.toString().startsWith(getString(R.string.login_data_own_scheme)); + if (savedInstanceState == null && !directLogin) { + onboarding.launchFirstRunIfNeeded(this); + } + + onlyAdd = getIntent().getBooleanExtra(KEY_ONLY_ADD, false) || checkIfViaSSO(getIntent()); + + // delete cookies for webView + deleteCookies(); + + // Workaround, for fixing a problem with Android Library Support v7 19 + //getWindow().requestFeature(Window.FEATURE_NO_TITLE); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.hide(); + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setDisplayShowHomeEnabled(false); + actionBar.setDisplayShowTitleEnabled(false); + } + + mIsFirstAuthAttempt = true; + + /// init activity state + mAccountMgr = AccountManager.get(this); + + /// get input values + mAction = getIntent().getByteExtra(EXTRA_ACTION, ACTION_CREATE); + + Bundle extras = getIntent().getExtras(); + + if (extras != null) { + mAccount = BundleExtensionsKt.getParcelableArgument(extras, EXTRA_ACCOUNT, Account.class); + } + + if (savedInstanceState != null) { + mWaitingForOpId = savedInstanceState.getLong(KEY_WAITING_FOR_OP_ID); + mIsFirstAuthAttempt = savedInstanceState.getBoolean(KEY_AUTH_IS_FIRST_ATTEMPT_TAG); + } + + boolean webViewLoginMethod = false; + String webloginUrl = null; + + if (MainApp.isClientBrandedPlus()) { + String baseUrl = MDMConfig.INSTANCE.getBaseUrl(this); + if (!TextUtils.isEmpty(baseUrl)) { + webloginUrl = baseUrl + WEB_LOGIN; + } + } + + if (!TextUtils.isEmpty(webloginUrl)) { + webViewLoginMethod = true; + } else if (getIntent().getBooleanExtra(EXTRA_USE_PROVIDER_AS_WEBLOGIN, false)) { + webViewLoginMethod = true; + webloginUrl = getString(R.string.provider_registration_server); + } else if (!TextUtils.isEmpty(getResources().getString(R.string.webview_login_url))) { + webViewLoginMethod = true; + webloginUrl = getResources().getString(R.string.webview_login_url); + showWebViewLoginUrl = getResources().getBoolean(R.bool.show_server_url_input); + } + + /// load user interface + if (webViewLoginMethod) { + accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater()); + setContentView(accountSetupWebviewBinding.getRoot()); + anonymouslyPostLoginRequest(webloginUrl); + } else { + accountSetupBinding = AccountSetupBinding.inflate(getLayoutInflater()); + setContentView(accountSetupBinding.getRoot()); + + /// initialize general UI elements + initOverallUi(); + + /// initialize block to be moved to single Fragment to check server and get info about it + + /// initialize block to be moved to single Fragment to retrieve and validate credentials + if (TextUtils.isEmpty(getString(R.string.enforce_servers))) { + initAuthorizationPreFragment(savedInstanceState); + } else { + showEnforcedServers(); + } + + initServerPreFragment(savedInstanceState); + } + + ProcessLifecycleOwner.get().getLifecycle().addObserver(lifecycleEventObserver); + } + + private void showEnforcedServers() { + showAuthStatus(); + accountSetupBinding.hostUrlFrame.setVisibility(View.GONE); + accountSetupBinding.hostUrlInputHelperText.setVisibility(View.GONE); + accountSetupBinding.scanQr.setVisibility(View.GONE); + accountSetupBinding.serversSpinner.setVisibility(View.VISIBLE); + + ArrayAdapter adapter = new ArrayAdapter<>(this, R.layout.enforced_servers_spinner); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + ArrayList servers = new ArrayList<>(); + servers.add(""); + adapter.add(getString(R.string.please_select_a_server)); + + ArrayList t = new Gson().fromJson(getString(R.string.enforce_servers), + new TypeToken>() { + } + .getType()); + + for (EnforcedServer e : t) { + adapter.add(e.getName()); + servers.add(e.getUrl()); + } + + accountSetupBinding.serversSpinner.setAdapter(adapter); + accountSetupBinding.serversSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + String url = servers.get(position); + + if (URLUtil.isValidUrl(url)) { + accountSetupBinding.hostUrlInput.setText(url); + checkOcServer(); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + // do nothing + } + }); + } + + private void deleteCookies() { + try { + CookieManager.getInstance().removeAllCookies(null); + } catch (Exception e) { + Log_OC.e(TAG, e.getMessage()); + } + } + + // region LoginFlow + private final ScheduledExecutorService loginFlowExecutorService = Executors.newSingleThreadScheduledExecutor(); + private boolean isLoginProcessCompleted = false; + private boolean isRedirectedToTheDefaultBrowser = false; + private String baseUrl; + + private void poolLogin() { + loginFlowExecutorService.scheduleWithFixedDelay(() -> { + if (!isLoginProcessCompleted) { + performLoginFlowV2(); + } + }, 0, 30, TimeUnit.SECONDS); + } + + /** + * This function facilitates the login process by anonymously posting a login request to a specified URL. + * After posting the request, it retrieves the login URL for completing the login flow. + * The login flow version used is v2. + * + * @param url The URL where the login request is to be anonymously posted. + * This URL should handle the login request and return the login URL. + * It's typically the entry point for the login process. + * Example: "..." + */ + private void anonymouslyPostLoginRequest(String url) { + if (TextUtils.isEmpty(url)) { + DisplayUtils.showSnackMessage(this, R.string.authenticator_activity_empty_base_url); + return; + } + baseUrl = url; + + singleThreadExecutor.execute(() -> { + String response = getResponseOfAnonymouslyPostLoginRequest(); + if (TextUtils.isEmpty(response)) { + DisplayUtils.showSnackMessage(AuthenticatorActivity.this, R.string.authenticator_activity_empty_response_message); + return; + } + + String loginUrl = extractLoginUrl(response); + runOnUiThread(() -> { + initLoginInfoView(); + launchDefaultWebBrowser(loginUrl); + }); + }); + } + + private String extractLoginUrl(String response) { + try { + authObject = gson.fromJson(response, AuthObject.class); + if (authObject != null && !TextUtils.isEmpty(authObject.getLogin())) { + return authObject.getLogin(); + } else { + Log_OC.e(TAG, "AuthObject parsing failed or login empty, trying JSONObject fallback"); + } + } catch (Exception e) { + Log_OC.e(TAG, "Error parsing AuthObject: " + e.getMessage(), e); + } + + try { + String fallbackUrl = getLoginFromJsonObject(response); + if (!TextUtils.isEmpty(fallbackUrl)) { + return fallbackUrl; + } else { + Log_OC.e(TAG, "Fallback JSONObject parsing failed or login empty"); + } + } catch (Exception e) { + Log_OC.e(TAG, "Error parsing fallback JSONObject: " + e.getMessage(), e); + } + + Log_OC.e(TAG, "Both AuthObject and fallback parsing failed, returning default login URL"); + DisplayUtils.showSnackMessage(this, R.string.authenticator_activity_login_error); + return getResources().getString(R.string.webview_login_url); + } + + private String getLoginFromJsonObject(String response) { + JsonObject jsonObject = JsonParser.parseString(response).getAsJsonObject(); + fallbackToken = jsonObject.getAsJsonObject("poll").get("token").getAsString(); + return jsonObject.get("login").getAsString(); + } + + private String getResponseOfAnonymouslyPostLoginRequest() { + PostMethod post = new PostMethod(baseUrl, false, new FormBody.Builder().build()); + PlainClient client = clientFactory.createPlainClient(); + post.execute(client); + return post.getResponseBodyAsString(); + } + + private void launchDefaultWebBrowser(String url) { + if (url == null || url.isBlank()) { + DisplayUtils.showSnackMessage(this, R.string.invalid_url); + return; + } + + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + PackageManager packageManager = getPackageManager(); + + if (intent.resolveActivity(packageManager) != null) { + startActivity(intent); + } else { + DisplayUtils.showSnackMessage(this, R.string.authenticator_activity_no_web_browser_found); + } + } catch (Exception e) { + Log_OC.e(TAG, "Exception launchDefaultWebBrowser: " + e); + DisplayUtils.showSnackMessage(this, R.string.authenticator_activity_login_error); + } + } + + private Pair extractPollUrlAndToken() { + if (authObject != null) { + final var poll = authObject.getPoll(); + String pollUrl = poll.getEndpoint(); + String token = poll.getToken(); + + if (TextUtils.isEmpty(pollUrl)) { + Log_OC.e(TAG, "auth object poll url is empty."); + } + if (TextUtils.isEmpty(token)) { + Log_OC.e(TAG, "auth object token is empty."); + } + + if (!TextUtils.isEmpty(pollUrl) && !TextUtils.isEmpty(token)) { + return new Pair<>(pollUrl, token); + } + } + + return new Pair<>(baseUrl + "/poll", fallbackToken); + } + + private void performLoginFlowV2() { + final var pollUrlAndToken = extractPollUrlAndToken(); + + RequestBody requestBody = new FormBody.Builder() + .add("token", pollUrlAndToken.second) + .build(); + + PlainClient client = clientFactory.createPlainClient(); + PostMethod post = new PostMethod(pollUrlAndToken.first, false, requestBody); + int status = post.execute(client); + String response = post.getResponseBodyAsString(); + + Log_OC.d(TAG, "performLoginFlowV2 status: " + status); + Log_OC.d(TAG, "performLoginFlowV2 response: " + response); + + if (!response.isEmpty()) { + runOnUiThread(() -> completeLoginFlow(response, status)); + } + } + + private void completeLoginFlow(String response, int status) { + try { + LoginUrlInfo loginUrlInfo = gson.fromJson(response, LoginUrlInfo.class); + if (loginUrlInfo == null) { + Log_OC.e(TAG, "cannot complete login flow loginUrl is null"); + return; + } + isLoginProcessCompleted = loginUrlInfo.isValid(status); + + if (accountSetupBinding != null) { + accountSetupBinding.hostUrlInput.setText(""); + } + + mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(loginUrlInfo.getServer()); + webViewUser = loginUrlInfo.getLoginName(); + webViewPassword = loginUrlInfo.getAppPassword(); + } catch (Exception e) { + Log_OC.d(TAG, "Error completeLoginFlow: " + e); + mServerStatusIcon = R.drawable.ic_alert; + mServerStatusText = getString(R.string.qr_could_not_be_read); + showServerStatus(); + } + + checkOcServer(); + loginFlowExecutorService.shutdown(); + ProcessLifecycleOwner.get().getLifecycle().removeObserver(lifecycleEventObserver); + } + + private final LifecycleEventObserver lifecycleEventObserver = ((lifecycleOwner, event) -> { + if (event == Lifecycle.Event.ON_START && authObject != null && !TextUtils.isEmpty(authObject.getPoll().getToken())) { + Log_OC.d(TAG, "Start poolLogin"); + poolLogin(); + } + }); + // endregion + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (accountSetupWebviewBinding != null && event.getAction() == KeyEvent.ACTION_DOWN && + keyCode == KeyEvent.KEYCODE_BACK) { + if (accountSetupWebviewBinding.loginWebview.canGoBack()) { + accountSetupWebviewBinding.loginWebview.goBack(); + } else { + finish(); + } + return true; + } + return super.onKeyDown(keyCode, event); + } + + private void setClient() { + accountSetupWebviewBinding.loginWebview.setWebViewClient(new NextcloudWebViewClient(getSupportFragmentManager()) { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + String url = request.getUrl().toString(); + if (url.startsWith(getString(R.string.login_data_own_scheme) + PROTOCOL_SUFFIX + "login/")) { + parseAndLoginFromWebView(url); + return true; + } + return false; + } + + @Override + public void onPageFinished(WebView view, String url) { + super.onPageFinished(view, url); + + accountSetupWebviewBinding.loginWebviewProgressBar.setVisibility(View.GONE); + accountSetupWebviewBinding.loginWebview.setVisibility(View.VISIBLE); + } + + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + accountSetupWebviewBinding.loginWebviewProgressBar.setVisibility(View.GONE); + accountSetupWebviewBinding.loginWebview.setVisibility(View.VISIBLE); + + InputStream resources = getResources().openRawResource(R.raw.custom_error); + String customError = DisplayUtils.getData(resources); + + if (!customError.isEmpty()) { + accountSetupWebviewBinding.loginWebview.loadData(customError, "text/html; charset=UTF-8", null); + } + } + }); + } + + private void parseAndLoginFromWebView(String dataString) { + try { + String prefix = getString(R.string.login_data_own_scheme) + PROTOCOL_SUFFIX + "login/"; + LoginUrlInfo loginUrlInfo = parseLoginDataUrl(prefix, dataString); + + if (accountSetupBinding != null) { + accountSetupBinding.hostUrlInput.setText(""); + } + mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(loginUrlInfo.getServer()); + webViewUser = loginUrlInfo.getLoginName(); + webViewPassword = loginUrlInfo.getAppPassword(); + } catch (Exception e) { + mServerStatusIcon = R.drawable.ic_alert; + mServerStatusText = getString(R.string.qr_could_not_be_read); + showServerStatus(); + } + checkOcServer(); + } + + /** + * parses a URI string and returns a login data object with the information from the URI string. + * + * @param prefix URI beginning, e.g. cloud://login/ + * @param dataString the complete URI + * @return login data + * @throws IllegalArgumentException when + */ + public static LoginUrlInfo parseLoginDataUrl(String prefix, String dataString) throws IllegalArgumentException { + if (dataString.length() < prefix.length()) { + throw new IllegalArgumentException("Invalid login URL detected"); + } + + // format is basically xxx://login/server:xxx&user:xxx&password while all variables are optional + String data = dataString.substring(prefix.length()); + + // parse data + String[] values = data.split("&"); + + if (values.length < 1 || values.length > 3) { + // error illegal number of URL elements detected + throw new IllegalArgumentException("Illegal number of login URL elements detected: " + values.length); + } + + LoginUrlInfo loginUrlInfo = new LoginUrlInfo("", "", ""); + + for (String value : values) { + if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) { + loginUrlInfo.setLoginName(URLDecoder.decode( + value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length()))); + } else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) { + loginUrlInfo.setAppPassword(URLDecoder.decode( + value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length()))); + } else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) { + loginUrlInfo.setServer(URLDecoder.decode( + value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length()))); + } + } + + return loginUrlInfo; + } + + /** + * Configures elements in the user interface under direct control of the Activity. + */ + private void initOverallUi() { + accountSetupBinding.hostUrlContainer.setEndIconOnClickListener(v -> checkOcServer()); + + accountSetupBinding.hostUrlInputHelperText.setText( + String.format(getString(R.string.login_url_helper_text), getString(R.string.app_name))); + + viewThemeUtils.platform.colorTextView(accountSetupBinding.hostUrlInputHelperText, ColorRole.ON_PRIMARY); + viewThemeUtils.platform.colorTextView(accountSetupBinding.serverStatusText, ColorRole.ON_PRIMARY); + viewThemeUtils.platform.colorTextView(accountSetupBinding.authStatusText, ColorRole.ON_PRIMARY); + viewThemeUtils.material.colorTextInputLayout(accountSetupBinding.hostUrlContainer, ColorRole.ON_PRIMARY); + viewThemeUtils.platform.colorEditTextOnPrimary(accountSetupBinding.hostUrlInput); + + if (deviceInfo.hasCamera(this)) { + accountSetupBinding.scanQr.setOnClickListener(v -> onScan()); + viewThemeUtils.platform.tintDrawable(this, accountSetupBinding.scanQr.getDrawable(), ColorRole.ON_PRIMARY); + } else { + accountSetupBinding.scanQr.setVisibility(View.GONE); + } + } + + /** + * @param savedInstanceState Saved activity state, as in {{@link #onCreate(Bundle)} + */ + private void initServerPreFragment(Bundle savedInstanceState) { + // step 1 - load and process relevant inputs (resources, intent, savedInstanceState) + if (savedInstanceState == null) { + if (mAccount != null) { + String baseUrl = mAccountMgr.getUserData(mAccount, Constants.KEY_OC_BASE_URL); + if (TextUtils.isEmpty(baseUrl)) { + mServerInfo.mBaseUrl = ""; + } else { + mServerInfo.mBaseUrl = baseUrl; + } + // TODO do next in a setter for mBaseUrl + mServerInfo.mIsSslConn = mServerInfo.mBaseUrl.startsWith(HTTPS_PROTOCOL); + mServerInfo.mVersion = accountManager.getServerVersion(mAccount); + } else { + mServerInfo.mBaseUrl = getString(R.string.webview_login_url).trim(); + mServerInfo.mIsSslConn = mServerInfo.mBaseUrl.startsWith(HTTPS_PROTOCOL); + } + } else { + mServerStatusText = savedInstanceState.getString(KEY_SERVER_STATUS_TEXT); + mServerStatusIcon = savedInstanceState.getInt(KEY_SERVER_STATUS_ICON); + + // TODO parcelable + mServerInfo.mIsSslConn = savedInstanceState.getBoolean(KEY_IS_SSL_CONN); + mServerInfo.mBaseUrl = savedInstanceState.getString(KEY_HOST_URL_TEXT); + String ocVersion = savedInstanceState.getString(KEY_OC_VERSION); + if (ocVersion != null) { + mServerInfo.mVersion = new OwnCloudVersion(ocVersion); + } + mServerInfo.mAuthMethod = AuthenticationMethod.valueOf( + savedInstanceState.getString(KEY_SERVER_AUTH_METHOD)); + + } + } + + /** + * @param savedInstanceState Saved activity state, as in {{@link #onCreate(Bundle)} + */ + private void initAuthorizationPreFragment(Bundle savedInstanceState) { + /// step 1 - load and process relevant inputs (resources, intent, savedInstanceState) + if (savedInstanceState != null) { + mAuthStatusText = savedInstanceState.getString(KEY_AUTH_STATUS_TEXT); + mAuthStatusIcon = savedInstanceState.getInt(KEY_AUTH_STATUS_ICON); + } + + /// step 2 - set properties of UI elements (text, visibility, enabled...) + showAuthStatus(); + + accountSetupBinding.hostUrlInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); + accountSetupBinding.hostUrlInput.setOnEditorActionListener(this); + } + + /** + * Saves relevant state before {@link #onPause()} + *

+ * See {@link super#onSaveInstanceState(Bundle)} + */ + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + //Log_OC.e(TAG, "onSaveInstanceState init" ); + super.onSaveInstanceState(outState); + + /// global state + outState.putLong(KEY_WAITING_FOR_OP_ID, mWaitingForOpId); + + outState.putBoolean(KEY_IS_SSL_CONN, mServerInfo.mIsSslConn); + outState.putString(KEY_HOST_URL_TEXT, mServerInfo.mBaseUrl); + if (mServerInfo.mVersion != null) { + outState.putString(KEY_OC_VERSION, mServerInfo.mVersion.getVersion()); + } + outState.putString(KEY_SERVER_AUTH_METHOD, mServerInfo.mAuthMethod.name()); + + /// authentication + outState.putBoolean(KEY_AUTH_IS_FIRST_ATTEMPT_TAG, mIsFirstAuthAttempt); + + /// AsyncTask (User and password) + if (mAsyncTask != null) { + mAsyncTask.cancel(true); + outState.putBoolean(KEY_ASYNC_TASK_IN_PROGRESS, true); + } else { + outState.putBoolean(KEY_ASYNC_TASK_IN_PROGRESS, false); + } + mAsyncTask = null; + } + + @Override + public void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + + // AsyncTask + boolean inProgress = savedInstanceState.getBoolean(KEY_ASYNC_TASK_IN_PROGRESS); + if (inProgress) { + String username = savedInstanceState.getString(KEY_USERNAME); + String password = savedInstanceState.getString(KEY_PASSWORD); + + OwnCloudCredentials credentials = OwnCloudCredentialsFactory.newBasicCredentials(username, password); + accessRootFolder(credentials); + } + } + + /** + * The redirection triggered by the OAuth authentication server as response to the GET AUTHORIZATION request is + * caught here. + *

+ * To make this possible, this activity needs to be qualified with android:launchMode = "singleTask" in the + * AndroidManifest.xml file. + */ + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + Log_OC.d(TAG, "onNewIntent()"); + + if (intent.getBooleanExtra(FirstRunActivity.EXTRA_EXIT, false)) { + super.finish(); + } + + onlyAdd = intent.getBooleanExtra(KEY_ONLY_ADD, false) || checkIfViaSSO(intent); + + // Passcode + passCodeManager.onActivityResumed(this); + + Uri data = intent.getData(); + if (data != null && data.toString().startsWith(getString(R.string.login_data_own_scheme))) { + if (!MDMConfig.INSTANCE.multiAccountSupport(this) && + accountManager.getAccounts().length == 1) { + DisplayUtils.showSnackMessage(this, R.string.no_mutliple_accounts_allowed); + finish(); + return; + } else { + parseAndLoginFromWebView(data.toString()); + } + } + + if (intent.getBooleanExtra(EXTRA_USE_PROVIDER_AS_WEBLOGIN, false)) { + accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater()); + setContentView(accountSetupWebviewBinding.getRoot()); + initSimpleSignupLogin(); + } + } + + @SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT") + @SuppressLint("SetJavaScriptEnabled") + private void initSimpleSignupLogin() { + viewThemeUtils.platform.colorCircularProgressBar(accountSetupWebviewBinding.loginWebviewProgressBar, ColorRole.ON_PRIMARY_CONTAINER); + accountSetupWebviewBinding.loginWebview.setVisibility(View.GONE); + new WebViewUtil().setProxyKKPlus(accountSetupWebviewBinding.loginWebview); + + accountSetupWebviewBinding.loginWebview.getSettings().setAllowFileAccess(false); + accountSetupWebviewBinding.loginWebview.getSettings().setJavaScriptEnabled(true); + accountSetupWebviewBinding.loginWebview.getSettings().setDomStorageEnabled(true); + + accountSetupWebviewBinding.loginWebview.getSettings().setUserAgentString(MainApp.getUserAgent()); + accountSetupWebviewBinding.loginWebview.getSettings().setSaveFormData(false); + accountSetupWebviewBinding.loginWebview.getSettings().setSavePassword(false); + + Map headers = new HashMap<>(); + headers.put(RemoteOperation.OCS_API_HEADER, RemoteOperation.OCS_API_HEADER_VALUE); + + new WebViewUtil().setProxyKKPlus(accountSetupWebviewBinding.loginWebview); + + accountSetupWebviewBinding.loginWebview.loadUrl(getString(R.string.provider_registration_server), headers); + accountSetupWebviewBinding.loginFlowV2.loginFlowInfoV2.setVisibility(View.GONE); + + setClient(); + } + + private boolean checkIfViaSSO(Intent intent) { + Bundle extras = intent.getExtras(); + if (extras == null) { + return false; + } else { + String authTokenType = extras.getString("authTokenType"); + return "SSO".equals(authTokenType); + } + } + + + /** + * The redirection triggered by the OAuth authentication server as response to the GET AUTHORIZATION, and deferred + * in {@link #onNewIntent(Intent)}, is processed here. + */ + @Override + protected void onResume() { + super.onResume(); + + // bind to Operations Service + mOperationsServiceConnection = new OperationsServiceConnection(); + if (!bindService(new Intent(this, OperationsService.class), + mOperationsServiceConnection, + Context.BIND_AUTO_CREATE)) { + DisplayUtils.showSnackMessage(accountSetupBinding.scroll, R.string.error_cant_bind_to_operations_service); + finish(); + } + + if (mOperationsServiceBinder != null) { + doOnResumeAndBound(); + } + } + + + @Override + protected void onPause() { + if (mOperationsServiceBinder != null) { + mOperationsServiceBinder.removeOperationListener(this); + } + + super.onPause(); + } + + @Override + protected void onDestroy() { + if (mOperationsServiceConnection != null) { + unbindService(mOperationsServiceConnection); + mOperationsServiceBinder = null; + } + + Log_OC.d(TAG, "AuthenticatorActivity onDestroy called"); + singleThreadExecutor.shutdown(); + super.onDestroy(); + } + + + @SuppressFBWarnings("NP") + private void checkOcServer() { + String uri; + + if (accountSetupBinding != null && + accountSetupBinding.hostUrlInput.getText() != null && + !accountSetupBinding.hostUrlInput.getText().toString().isEmpty()) { + uri = accountSetupBinding.hostUrlInput.getText().toString().trim(); + } else { + uri = mServerInfo.mBaseUrl; + } + + mServerInfo = new GetServerInfoOperation.ServerInfo(); + + if (!uri.isEmpty()) { + if (accountSetupBinding != null) { + uri = AuthenticatorUrlUtils.INSTANCE.stripIndexPhpOrAppsFiles(uri); + accountSetupBinding.hostUrlInput.setText(uri); + } + + try { + uri = AuthenticatorUrlUtils.INSTANCE.normalizeScheme(uri); + } catch (IllegalArgumentException ex) { + // Let the Nextcloud library check the error of the malformed URI + Log_OC.e(TAG, "Invalid URL", ex); + } + + // Handle internationalized domain names + try { + uri = DisplayUtils.convertIdn(uri, true); + } catch (IllegalArgumentException ex) { + // Let the Nextcloud library check the error of the malformed URI + Log_OC.e(TAG, "Error converting internationalized domain name " + uri, ex); + } + + if (accountSetupBinding != null) { + mServerStatusText = getResources().getString(R.string.auth_testing_connection); + mServerStatusIcon = R.drawable.progress_small; + showServerStatus(); + } + + // TODO maybe do this via async task + Intent getServerInfoIntent = new Intent(); + getServerInfoIntent.setAction(OperationsService.ACTION_GET_SERVER_INFO); + getServerInfoIntent.putExtra(OperationsService.EXTRA_SERVER_URL, + AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(uri)); + + if (mOperationsServiceBinder != null) { + mWaitingForOpId = mOperationsServiceBinder.queueNewOperation(getServerInfoIntent); + } else { + Log_OC.e(TAG, "Server check tried with OperationService unbound!"); + } + + } + } + + /** + * Tests the credentials entered by the user performing a check of existence on the root folder of the ownCloud + * server. + */ + private void checkBasicAuthorization(@Nullable String webViewUsername, @Nullable String webViewPassword) { + // be gentle with the user + IndeterminateProgressDialog dialog = IndeterminateProgressDialog.newInstance(R.string.auth_trying_to_login, + true); + FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); + ft.add(dialog, WAIT_DIALOG_TAG); + ft.commitAllowingStateLoss(); + + // validate credentials accessing the root folder + OwnCloudCredentials credentials = OwnCloudCredentialsFactory.newBasicCredentials(webViewUsername, + webViewPassword); + accessRootFolder(credentials); + } + + private void accessRootFolder(OwnCloudCredentials credentials) { + mAsyncTask = new AuthenticatorAsyncTask(this); + Object[] params = {mServerInfo.mBaseUrl, credentials}; + mAsyncTask.execute(params); + } + + /** + * Callback method invoked when a RemoteOperation executed by this Activity finishes. + *

+ * Dispatches the operation flow to the right method. + */ + @Override + public void onRemoteOperationFinish(RemoteOperation operation, RemoteOperationResult result) { + if (operation instanceof GetServerInfoOperation) { + if (operation.hashCode() == mWaitingForOpId) { + onGetServerInfoFinish(result); + } // else nothing ; only the last check operation is considered; + // multiple can be started if the user amends a URL quickly + + } else if (operation instanceof GetUserInfoRemoteOperation) { + onGetUserNameFinish(result); + } + } + + private void onGetUserNameFinish(RemoteOperationResult result) { + mWaitingForOpId = Long.MAX_VALUE; + if (result.isSuccess()) { + boolean success = false; + + if (mAction == ACTION_CREATE) { + success = createAccount(result); + } else { + try { + updateAccountAuthentication(); + success = true; + + } catch (AccountNotFoundException e) { + Log_OC.e(TAG, "Account " + mAccount + " was removed!", e); + DisplayUtils.showSnackMessage(accountSetupBinding.scroll, R.string.auth_account_does_not_exist); + finish(); + } + } + + if (success) { + finish(); + } + } else { + // TODO check + int statusText = result.getCode() == ResultCode.MAINTENANCE_MODE ? R.string.maintenance_mode : R.string.auth_fail_get_user_name; + updateStatusIconFailUserName(statusText); + showAuthStatus(); + Log_OC.e(TAG, "Access to user name failed: " + result.getLogMessage()); + } + } + + /** + * Processes the result of the server check performed when the user finishes the enter of the server URL. + * + * @param result Result of the check. + */ + private void onGetServerInfoFinish(RemoteOperationResult result) { + /// update activity state + mWaitingForOpId = Long.MAX_VALUE; + + if (result.isSuccess()) { + /// SUCCESS means: + // 1. connection succeeded, and we know if it's SSL or not + // 2. server is installed + // 3. we got the server version + // 4. we got the authentication method required by the server + mServerInfo = (GetServerInfoOperation.ServerInfo) (result.getData().get(0)); + + if (webViewUser != null && !webViewUser.isEmpty() && + webViewPassword != null && !webViewPassword.isEmpty()) { + checkBasicAuthorization(webViewUser, webViewPassword); + } else { + accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater()); + setContentView(accountSetupWebviewBinding.getRoot()); + + if (!isLoginProcessCompleted) { + if (!isRedirectedToTheDefaultBrowser) { + anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN); + isRedirectedToTheDefaultBrowser = true; + } else { + initLoginInfoView(); + } + } + } + } else { + updateServerStatusIconAndText(result); + showServerStatus(); + } + + // very special case (TODO: move to a common place for all the remote operations) + if (result.getCode() == ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED) { + showUntrustedCertDialog(result); + } + } + + // region LoginInfoView + private void initLoginInfoView() { + LinearLayout loginFlowLayout = accountSetupWebviewBinding.loginFlowV2.getRoot(); + MaterialButton cancelButton = accountSetupWebviewBinding.loginFlowV2.cancelButton; + loginFlowLayout.setVisibility(View.VISIBLE); + + // add margin bottom to prevent overlapping with system bars + ViewCompat.setOnApplyWindowInsetsListener(loginFlowLayout, (view, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + view.setPadding( + view.getPaddingLeft(), + view.getPaddingTop(), + view.getPaddingRight(), + systemBars.bottom); + return insets; + }); + + cancelButton.setOnClickListener(v -> { + loginFlowExecutorService.shutdown(); + ProcessLifecycleOwner.get().getLifecycle().removeObserver(lifecycleEventObserver); + recreate(); + }); + } + // endregion + + /** + * Chooses the right icon and text to show to the user for the received operation result. + * + * @param result Result of a remote operation performed in this activity + */ + private void updateServerStatusIconAndText(RemoteOperationResult result) { + mServerStatusIcon = R.drawable.ic_alert; // the most common case in the switch below + + switch (result.getCode()) { + case OK_SSL: + mServerStatusIcon = R.drawable.ic_lock_white; + mServerStatusText = getResources().getString(R.string.auth_secure_connection); + break; + + case OK_NO_SSL: + case OK: + if (accountSetupBinding.hostUrlInput.getText() != null && + accountSetupBinding.hostUrlInput + .getText() + .toString() + .trim() + .toLowerCase(Locale.ROOT) + .startsWith(HTTP_PROTOCOL)) { + mServerStatusText = getResources().getString(R.string.auth_connection_established); + mServerStatusIcon = R.drawable.ic_ok; + } else { + mServerStatusText = getResources().getString(R.string.auth_nossl_plain_ok_title); + mServerStatusIcon = R.drawable.ic_lock_open_white; + } + break; + + case NO_NETWORK_CONNECTION: + mServerStatusIcon = R.drawable.no_network; + mServerStatusText = getResources().getString(R.string.auth_no_net_conn_title); + break; + + case SSL_RECOVERABLE_PEER_UNVERIFIED: + mServerStatusText = getResources().getString(R.string.auth_ssl_unverified_server_title); + break; + case BAD_OC_VERSION: + mServerStatusText = getResources().getString(R.string.auth_bad_oc_version_title); + break; + case WRONG_CONNECTION: + mServerStatusText = getResources().getString(R.string.auth_wrong_connection_title); + break; + case TIMEOUT: + mServerStatusText = getResources().getString(R.string.auth_timeout_title); + break; + case INCORRECT_ADDRESS: + mServerStatusText = getResources().getString(R.string.auth_incorrect_address_title); + break; + case SSL_ERROR: + mServerStatusText = getResources().getString(R.string.auth_ssl_general_error_title); + break; + case UNAUTHORIZED: + mServerStatusText = getResources().getString(R.string.auth_unauthorized); + break; + case HOST_NOT_AVAILABLE: + mServerStatusText = getResources().getString(R.string.auth_unknown_host_title); + break; + case INSTANCE_NOT_CONFIGURED: + mServerStatusText = getResources().getString(R.string.auth_not_configured_title); + break; + case FILE_NOT_FOUND: + mServerStatusText = getResources().getString(R.string.auth_incorrect_path_title); + break; + case OAUTH2_ERROR: + mServerStatusText = getResources().getString(R.string.auth_oauth_error); + break; + case OAUTH2_ERROR_ACCESS_DENIED: + mServerStatusText = getResources().getString(R.string.auth_oauth_error_access_denied); + break; + case UNHANDLED_HTTP_CODE: + mServerStatusText = getResources().getString(R.string.auth_unknown_error_http_title); + break; + case UNKNOWN_ERROR: + if (result.getException() != null && + !TextUtils.isEmpty(result.getException().getMessage())) { + mServerStatusText = getResources().getString( + R.string.auth_unknown_error_exception_title, + result.getException().getMessage() + ); + } else { + mServerStatusText = getResources().getString(R.string.auth_unknown_error_title); + } + break; + case OK_REDIRECT_TO_NON_SECURE_CONNECTION: + mServerStatusIcon = R.drawable.ic_lock_open_white; + mServerStatusText = getResources().getString(R.string.auth_redirect_non_secure_connection_title); + break; + case MAINTENANCE_MODE: + mServerStatusText = getResources().getString(R.string.maintenance_mode); + break; + case UNTRUSTED_DOMAIN: + mServerStatusText = getResources().getString(R.string.untrusted_domain); + break; + default: + mServerStatusText = EMPTY_STRING; + mServerStatusIcon = 0; + break; + } + } + + /** + * Chooses the right icon and text to show to the user for the received operation result. + * + * @param result Result of a remote operation performed in this activity + */ + private void updateAuthStatusIconAndText(RemoteOperationResult result) { + mAuthStatusIcon = R.drawable.ic_alert; // the most common case in the switch below + + switch (result.getCode()) { + case OK_SSL: + mAuthStatusIcon = R.drawable.ic_lock_white; + mAuthStatusText = getResources().getString(R.string.auth_secure_connection); + break; + + case OK_NO_SSL: + case OK: + if (showWebViewLoginUrl) { + if (accountSetupBinding.hostUrlInput.getText() != null && + accountSetupBinding.hostUrlInput + .getText() + .toString() + .trim() + .toLowerCase(Locale.ROOT) + .startsWith(HTTP_PROTOCOL)) { + mAuthStatusText = getResources().getString(R.string.auth_connection_established); + mAuthStatusIcon = R.drawable.ic_ok; + } else { + mAuthStatusText = getResources().getString(R.string.auth_nossl_plain_ok_title); + mAuthStatusIcon = R.drawable.ic_lock_open_white; + } + } + break; + + case NO_NETWORK_CONNECTION: + mAuthStatusIcon = R.drawable.no_network; + mAuthStatusText = getResources().getString(R.string.auth_no_net_conn_title); + break; + + case SSL_RECOVERABLE_PEER_UNVERIFIED: + mAuthStatusText = getResources().getString(R.string.auth_ssl_unverified_server_title); + break; + case TIMEOUT: + mAuthStatusText = getResources().getString(R.string.auth_timeout_title); + break; + case HOST_NOT_AVAILABLE: + mAuthStatusText = getResources().getString(R.string.auth_unknown_host_title); + break; + case ACCOUNT_NOT_NEW: + mAuthStatusText = getString(R.string.auth_account_not_new); + if (!showWebViewLoginUrl) { + showErrorAndFinishActivity(); + } + break; + case UNHANDLED_HTTP_CODE: + default: + mAuthStatusText = ErrorMessageAdapter.getErrorCauseMessage(result, null, getResources()); + if (!showWebViewLoginUrl) { + showErrorAndFinishActivity(); + } + } + } + + private void showErrorAndFinishActivity() { + DisplayUtils.showSnackMessage(this, mAuthStatusText); + finish(); + } + + private void updateStatusIconFailUserName(int failedStatusText) { + mAuthStatusIcon = R.drawable.ic_alert; + mAuthStatusText = getResources().getString(failedStatusText); + } + + /** + * Processes the result of the access check performed to try the user credentials. + *

+ * Creates a new account through the AccountManager. + * + * @param result Result of the operation. + */ + @Override + public void onAuthenticatorTaskCallback(RemoteOperationResult result) { + mWaitingForOpId = Long.MAX_VALUE; + dismissWaitingDialog(); + mAsyncTask = null; + + if (result.isSuccess()) { + Log_OC.d(TAG, "Successful access - time to save the account"); + + boolean success = false; + + if (mAction == ACTION_CREATE) { + success = createAccount(result); + } else { + try { + updateAccountAuthentication(); + success = true; + + } catch (AccountNotFoundException e) { + Log_OC.e(TAG, "Account " + mAccount + " was removed!", e); + DisplayUtils.showSnackMessage(accountSetupBinding.scroll, R.string.auth_account_does_not_exist); + finish(); + } + } + + // Reset webView + webViewPassword = null; + webViewUser = null; + deleteCookies(); + + if (success) { + accountManager.setCurrentOwnCloudAccount(mAccount.name); + getUserCapabilitiesAndFinish(); + } else { + accountSetupBinding = AccountSetupBinding.inflate(getLayoutInflater()); + setContentView(accountSetupBinding.getRoot()); + initOverallUi(); + + accountSetupBinding.hostUrlInput.setText(mServerInfo.mBaseUrl); + accountSetupBinding.serverStatusText.setVisibility(View.GONE); + showAuthStatus(); + } + + } else if (result.isServerFail() || result.isException()) { + /// server errors or exceptions in authorization take to requiring a new check of the server + mServerInfo = new GetServerInfoOperation.ServerInfo(); + + // update status icon and text + updateServerStatusIconAndText(result); + showServerStatus(); + mAuthStatusIcon = 0; + mAuthStatusText = EMPTY_STRING; + + // very special case (TODO: move to a common place for all the remote operations) + if (result.getCode() == ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED) { + showUntrustedCertDialog(result); + } + + } else { // authorization fail due to client side - probably wrong credentials + if (accountSetupWebviewBinding != null) { + anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN); + } else { + DisplayUtils.showSnackMessage(this, R.string.auth_access_failed, result.getLogMessage(this)); + + // init webView again + updateAuthStatusIconAndText(result); + } + + // reset webview + webViewPassword = null; + webViewUser = null; + deleteCookies(); + + Log_OC.d(TAG, "Access failed: " + result.getLogMessage()); + } + } + + private void endSuccess() { + if (!onlyAdd) { + if (MDMConfig.INSTANCE.enforceProtection(this) && Objects.equals(preferences.getLockPreference(), SettingsActivity.LOCK_NONE)) { + Intent i = new Intent(this, SettingsActivity.class); + startActivity(i); + } else { + Intent i = new Intent(this, FileDisplayActivity.class); + i.setAction(FileDisplayActivity.RESTART); + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(i); + } + } + + finish(); + } + + private void getUserCapabilitiesAndFinish() { + final Handler handler = new Handler(); + final Optional user = accountManager.getUser(mAccount.name); + + if (user.isPresent()) { + Executors.newSingleThreadExecutor().execute(() -> { + try { + final FileDataStorageManager storageManager = new FileDataStorageManager(user.get(), getContentResolver()); + new GetCapabilitiesOperation(storageManager).execute(MainApp.getAppContext()); + handler.post(this::endSuccess); + } catch (Exception e) { + Log_OC.e(TAG, "Failed to fetch capabilities", e); + handler.post(this::endSuccess); + } + }); + } else { + Log_OC.w(TAG, "User not present for fetching capabilities"); + endSuccess(); + } + } + + /** + * Updates the authentication token. + *

+ * Sets the proper response so that the AccountAuthenticator that started this activity saves a new authorization + * token for mAccount. + *

+ * Kills the session kept by OwnCloudClientManager so that a new one will created with the new credentials when + * needed. + */ + private void updateAccountAuthentication() throws AccountNotFoundException { + Bundle response = new Bundle(); + response.putString(AccountManager.KEY_ACCOUNT_NAME, mAccount.name); + response.putString(AccountManager.KEY_ACCOUNT_TYPE, mAccount.type); + response.putString(AccountManager.KEY_AUTHTOKEN, webViewPassword); + mAccountMgr.setPassword(mAccount, webViewPassword); + + // remove managed clients for this account to enforce creation with fresh credentials + OwnCloudAccount ocAccount = new OwnCloudAccount(mAccount, this); + OwnCloudClientManagerFactory.getDefaultSingleton().removeClientFor(ocAccount); + + setAccountAuthenticatorResult(response); + Intent intent = new Intent(); + intent.putExtras(response); + setResult(RESULT_OK, intent); + } + + /** + * Creates a new account through the Account Authenticator that started this activity. + *

+ * This makes the account permanent. + *

+ * TODO Decide how to name the OAuth accounts + */ + @SuppressFBWarnings("DMI") + @SuppressLint("TrulyRandom") + protected boolean createAccount(RemoteOperationResult authResult) { + String accountType = MainApp.getAccountType(this); + + // create and save new ownCloud account + String lastPermanentLocation = authResult.getLastPermanentLocation(); + if (lastPermanentLocation != null) { + mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.trimWebdavSuffix(lastPermanentLocation); + } + + Uri uri = Uri.parse(mServerInfo.mBaseUrl); + // used for authenticate on every login/network connection, determined by first login (weblogin/old login) + // can be anything: email, name, name with whitespaces + String loginName = webViewUser; + + String accountName = AccountUtils.buildAccountName(uri, loginName); + Account newAccount = new Account(accountName, accountType); + if (accountManager.exists(newAccount)) { + // fail - not a new account, but an existing one; disallow + RemoteOperationResult result = new RemoteOperationResult(ResultCode.ACCOUNT_NOT_NEW); + + updateAuthStatusIconAndText(result); + showAuthStatus(); + + Log_OC.d(TAG, result.getLogMessage()); + return false; + + } else { + UserInfo userInfo = authResult.getResultData(); + if (userInfo == null) { + Log_OC.e(this, "Could not read user data!"); + return false; + } + + mAccount = newAccount; + mAccountMgr.addAccountExplicitly(mAccount, webViewPassword, null); + mAccountMgr.notifyAccountAuthenticated(mAccount); + + // add the new account as default in preferences, if there is none already + User defaultAccount = accountManager.getUser(); + if (defaultAccount.isAnonymous()) { + SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(this).edit(); + editor.putString("select_oc_account", accountName); + editor.apply(); + } + + /// prepare result to return to the Authenticator + // TODO check again what the Authenticator makes with it; probably has the same + // effect as addAccountExplicitly, but it's not well done + final Intent intent = new Intent(); + intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType); + intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, mAccount.name); + intent.putExtra(AccountManager.KEY_USERDATA, loginName); + + /// add user data to the new account; TODO probably can be done in the last parameter + // addAccountExplicitly, or in KEY_USERDATA + mAccountMgr.setUserData(mAccount, Constants.KEY_OC_VERSION, mServerInfo.mVersion.getVersion()); + mAccountMgr.setUserData(mAccount, Constants.KEY_OC_BASE_URL, mServerInfo.mBaseUrl); + mAccountMgr.setUserData(mAccount, Constants.KEY_DISPLAY_NAME, userInfo.getDisplayName()); + mAccountMgr.setUserData(mAccount, Constants.KEY_USER_ID, userInfo.getId()); + mAccountMgr.setUserData(mAccount, + Constants.KEY_OC_ACCOUNT_VERSION, + Integer.toString(UserAccountManager.ACCOUNT_VERSION_WITH_PROPER_ID)); + + + setAccountAuthenticatorResult(intent.getExtras()); + setResult(RESULT_OK, intent); + + // notify Document Provider + DocumentsStorageProvider.notifyRootsChanged(this); + + return true; + } + } + + public void onScan() { + if (PermissionUtil.checkSelfPermission(this, Manifest.permission.CAMERA)) { + startQRScanner(); + } else { + PermissionUtil.requestCameraPermission(this, PERMISSIONS_CAMERA); + } + } + + private void startQRScanner() { + Intent intent = new Intent(this, QrCodeActivity.class); + qrScanResultLauncher.launch(intent); + } + + private final ActivityResultLauncher qrScanResultLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + + if (data == null) { + return; + } + + String resultData = data.getStringExtra("com.blikoon.qrcodescanner.got_qr_scan_relult"); + + if (resultData == null || !resultData.startsWith(getString(R.string.login_data_own_scheme))) { + mServerStatusIcon = R.drawable.ic_alert; + mServerStatusText = "QR Code could not be read!"; + showServerStatus(); + return; + } + + if (!MDMConfig.INSTANCE.multiAccountSupport(this) && + accountManager.getAccounts().length == 1) { + DisplayUtils.showSnackMessage(this, R.string.no_mutliple_accounts_allowed); + } else { + String onetimePrefix = getString(R.string.login_data_own_scheme) + PROTOCOL_SUFFIX + "onetime-login/"; + + if (resultData.startsWith(onetimePrefix)) { + parseAndLoginFromOneTimeCode(onetimePrefix, resultData); + } else { + parseAndLoginFromWebView(resultData); + } + } + } + }); + + private void parseAndLoginFromOneTimeCode(String onetimePrefix, String resultData) { + LoginUrlInfo loginUrlInfo = parseLoginDataUrl(onetimePrefix, resultData); + + GenerateOneTimeAppPasswordRemoteOperation generateOneTimeAppPasswordRemoteOperation = new GenerateOneTimeAppPasswordRemoteOperation(); + + String credentials = Credentials.basic(loginUrlInfo.getLoginName(), loginUrlInfo.getAppPassword()); + NextcloudClient nextcloudClient = new NextcloudClient(Uri.parse(loginUrlInfo.getServer()), loginUrlInfo.getLoginName(), credentials, this); + + new Thread(() -> { + RemoteOperationResult otpResult = nextcloudClient.execute(generateOneTimeAppPasswordRemoteOperation); + + if (otpResult.isSuccess()) { + mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(loginUrlInfo.getServer()); + webViewUser = loginUrlInfo.getLoginName(); + webViewPassword = otpResult.getResultData(); + + runOnUiThread(this::checkOcServer); + } else { + mServerStatusIcon = R.drawable.ic_alert; + mServerStatusText = getString(R.string.qr_could_not_be_read); + + runOnUiThread(this::showServerStatus); + } + }).start(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, + @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode == PERMISSIONS_CAMERA) {// If request is cancelled, result arrays are empty. + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // permission was granted + startQRScanner(); + } + } else { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } + + /** + * /** Updates the content and visibility state of the icon and text associated to the last check on the ownCloud + * server. + */ + private void showServerStatus() { + if (accountSetupBinding == null) { + return; + } + + if (mServerStatusIcon == NO_ICON && EMPTY_STRING.equals(mServerStatusText)) { + accountSetupBinding.serverStatusText.setVisibility(View.INVISIBLE); + } else { + accountSetupBinding.serverStatusText.setText(mServerStatusText); + accountSetupBinding.serverStatusText.setCompoundDrawablesWithIntrinsicBounds(mServerStatusIcon, 0, 0, 0); + accountSetupBinding.serverStatusText.setVisibility(View.VISIBLE); + } + } + + /** + * Updates the content and visibility state of the icon and text associated to the interactions with the OAuth + * authorization server. + */ + private void showAuthStatus() { + if (accountSetupBinding != null) { + if (mAuthStatusIcon == NO_ICON && EMPTY_STRING.equals(mAuthStatusText)) { + accountSetupBinding.authStatusText.setVisibility(View.INVISIBLE); + } else { + accountSetupBinding.authStatusText.setText(mAuthStatusText); + accountSetupBinding.authStatusText.setCompoundDrawablesWithIntrinsicBounds(mAuthStatusIcon, 0, 0, 0); + accountSetupBinding.authStatusText.setVisibility(View.VISIBLE); + } + } + } + + /** + * Called when the 'action' button in an IME is pressed ('enter' in software keyboard). + *

+ * Used to trigger the authentication check when the user presses 'enter' after writing the password, or to throw + * the server test when the only field on screen is the URL input field. + */ + @Override + public boolean onEditorAction(TextView inputField, int actionId, KeyEvent event) { + if ((actionId == EditorInfo.IME_ACTION_NEXT || actionId == EditorInfo.IME_NULL) + && inputField != null && inputField.equals(accountSetupBinding.hostUrlInput)) { + checkOcServer(); + } + return false; // always return false to grant that the software keyboard is hidden anyway + } + + + /** + * Show untrusted cert dialog + */ + private void showUntrustedCertDialog(RemoteOperationResult result) { + // Show a dialog with the certificate info + SslUntrustedCertDialog dialog = SslUntrustedCertDialog. + newInstanceForFullSslError((CertificateCombinedException) result.getException()); + FragmentManager fm = getSupportFragmentManager(); + FragmentTransaction ft = fm.beginTransaction(); + ft.addToBackStack(null); + dialog.show(ft, UNTRUSTED_CERT_DIALOG_TAG); + + } + + private void doOnResumeAndBound() { + mOperationsServiceBinder.addOperationListener(this, mHandler); + if (mWaitingForOpId <= Integer.MAX_VALUE) { + mOperationsServiceBinder.dispatchResultIfFinished((int) mWaitingForOpId, this); + } + } + + private void dismissWaitingDialog() { + Fragment frag = getSupportFragmentManager().findFragmentByTag(WAIT_DIALOG_TAG); + if (frag instanceof DialogFragment dialog) { + try { + dialog.dismiss(); + } catch (IllegalStateException e) { + Log_OC.e(TAG, e.getMessage()); + dialog.dismissAllowingStateLoss(); + } + } + } + + /** + * Implements callback methods for service binding. + */ + private class OperationsServiceConnection implements ServiceConnection { + + @Override + public void onServiceConnected(ComponentName component, IBinder service) { + if (component.equals( + new ComponentName(AuthenticatorActivity.this, OperationsService.class) + )) { + mOperationsServiceBinder = (OperationsServiceBinder) service; + + Uri data = getIntent().getData(); + if (data != null && data.toString().startsWith(getString(R.string.login_data_own_scheme))) { + try { + String prefix = getString(R.string.login_data_own_scheme) + PROTOCOL_SUFFIX + "login/"; + LoginUrlInfo loginUrlInfo = parseLoginDataUrl(prefix, data.toString()); + + mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(loginUrlInfo.getServer()); + webViewUser = loginUrlInfo.getLoginName(); + webViewPassword = loginUrlInfo.getAppPassword(); + doOnResumeAndBound(); + checkOcServer(); + } catch (Exception e) { + mServerStatusIcon = R.drawable.ic_alert; + mServerStatusText = getString(R.string.qr_could_not_be_read); + showServerStatus(); + } + } else { + doOnResumeAndBound(); + } + } + } + + @Override + public void onServiceDisconnected(ComponentName component) { + if (component.equals( + new ComponentName(AuthenticatorActivity.this, OperationsService.class) + )) { + Log_OC.e(TAG, "Operations service crashed"); + mOperationsServiceBinder = null; + } + } + } + + + /** + * Called from SslValidatorDialog when a new server certificate was correctly saved. + */ + public void onSavedCertificate() { + checkOcServer(); + } + + /** + * Called from SslValidatorDialog when a new server certificate could not be saved when the user requested it. + */ + @Override + public void onFailedSavingCertificate() { + DisplayUtils.showSnackMessage(this, R.string.ssl_validator_not_saved); + } +} diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorAsyncTask.kt b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorAsyncTask.kt new file mode 100644 index 000000000000..0a66a9049cab --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorAsyncTask.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2013-2015 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +@file:Suppress("DEPRECATION") + +package com.owncloud.android.authentication + +import android.app.Activity +import android.content.Context +import android.os.AsyncTask +import androidx.core.net.toUri +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.OwnCloudCredentials +import com.owncloud.android.lib.common.UserInfo +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation +import com.owncloud.android.lib.resources.users.GetUserInfoRemoteOperation +import java.lang.ref.WeakReference + +/** + * Async Task to verify the credentials of a user. + */ +class AuthenticatorAsyncTask(activity: Activity) : AsyncTask?>() { + private val mWeakContext: WeakReference + private val mListener: WeakReference + + init { + mWeakContext = WeakReference(activity.applicationContext) + mListener = WeakReference(activity as OnAuthenticatorTaskListener) + } + + @Deprecated("Deprecated in Java") + override fun doInBackground(vararg params: Any?): RemoteOperationResult { + val result: RemoteOperationResult + + if (params.size == 2 && mWeakContext.get() != null) { + val url = params[0] as String + val credentials = params[1] as OwnCloudCredentials + val context = mWeakContext.get() + + // Client + val uri = url.toUri() + val nextcloudClient = OwnCloudClientFactory.createNextcloudClient( + uri, + credentials.username, + credentials.toOkHttpCredentials(), + context, + true + ) + + // Operation - get display name + val userInfoResult = GetUserInfoRemoteOperation().execute(nextcloudClient) + + // Operation - try credentials + if (userInfoResult.isSuccess) { + val client = OwnCloudClientFactory.createOwnCloudClient(uri, context, true) + client.userId = userInfoResult.resultData?.id + client.credentials = credentials + val operation = ExistenceCheckRemoteOperation(OCFile.ROOT_PATH, SUCCESS_IF_ABSENT) + + @Suppress("UNCHECKED_CAST") + result = operation.execute(client) as RemoteOperationResult + if (operation.wasRedirected()) { + val redirectionPath = operation.redirectionPath + val permanentLocation = redirectionPath.lastPermanentLocation + result.lastPermanentLocation = permanentLocation + } + result.setResultData(userInfoResult.resultData) + } else { + result = userInfoResult + } + } else { + result = RemoteOperationResult(RemoteOperationResult.ResultCode.UNKNOWN_ERROR) + } + + return result + } + + @Deprecated("Deprecated in Java") + override fun onPostExecute(result: RemoteOperationResult?) { + result?.let { + val listener = mListener.get() + listener?.onAuthenticatorTaskCallback(it) + } + } + + /* + * Interface to retrieve data from recognition task + */ + interface OnAuthenticatorTaskListener { + fun onAuthenticatorTaskCallback(result: RemoteOperationResult?) + } + + companion object { + private const val SUCCESS_IF_ABSENT = false + } +} diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorUrlUtils.kt b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorUrlUtils.kt new file mode 100644 index 000000000000..87d7be6cb9b9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorUrlUtils.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2017 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.authentication + +import java.net.URI + +/** + * Helper class for authenticator-URL related logic. + */ +object AuthenticatorUrlUtils { + + private const val REMOTE_PHP_PATH = "/remote.php/dav" + + fun normalizeUrlSuffix(url: String): String { + var normalizedUrl = url + if (normalizedUrl.endsWith("/")) { + normalizedUrl = normalizedUrl.substring(0, normalizedUrl.length - 1) + } + return trimUrlWebdav(normalizedUrl) + } + + fun trimWebdavSuffix(url: String): String { + var trimmedUrl = url + while (trimmedUrl.endsWith("/")) { + trimmedUrl = trimmedUrl.substring(0, url.length - 1) + } + val pos = trimmedUrl.lastIndexOf(REMOTE_PHP_PATH) + if (pos >= 0) { + trimmedUrl = trimmedUrl.substring(0, pos) + } + return trimmedUrl + } + + private fun trimUrlWebdav(url: String): String = if (url.lowercase().endsWith(REMOTE_PHP_PATH)) { + url.substring(0, url.length - REMOTE_PHP_PATH.length) + } else { + url + } + + fun stripIndexPhpOrAppsFiles(url: String): String { + var strippedUrl = url + if (strippedUrl.endsWith("/index.php")) { + strippedUrl = strippedUrl.substring(0, strippedUrl.lastIndexOf("/index.php")) + } else if (strippedUrl.contains("/index.php/apps/")) { + strippedUrl = strippedUrl.substring(0, strippedUrl.lastIndexOf("/index.php/apps/")) + } + return strippedUrl + } + + fun normalizeScheme(url: String): String = if (url.matches("[a-zA-Z][a-zA-Z0-9+.-]+://.+".toRegex())) { + val uri = URI.create(url) + val lcScheme = uri.scheme.lowercase() + String.format("%s:%s", lcScheme, uri.rawSchemeSpecificPart) + } else { + url + } +} diff --git a/app/src/main/java/com/owncloud/android/authentication/DeepLinkLoginActivity.kt b/app/src/main/java/com/owncloud/android/authentication/DeepLinkLoginActivity.kt new file mode 100644 index 000000000000..3d9dca0efe31 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/DeepLinkLoginActivity.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2018-2022 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.authentication + +import android.os.Bundle +import android.widget.TextView +import com.nextcloud.client.di.Injectable +import com.nextcloud.utils.mdm.MDMConfig +import com.owncloud.android.R +import com.owncloud.android.utils.DisplayUtils + +class DeepLinkLoginActivity : + AuthenticatorActivity(), + Injectable { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!MDMConfig.multiAccountSupport(this) && accountManager.accounts.size == 1) { + DisplayUtils.showSnackMessage(this, R.string.no_mutliple_accounts_allowed) + return + } + + setContentView(R.layout.deep_link_login) + + intent.data?.let { + try { + val prefix = getString(R.string.login_data_own_scheme) + PROTOCOL_SUFFIX + "login/" + val loginUrlInfo = parseLoginDataUrl(prefix, it.toString()) + val loginText = findViewById(R.id.loginInfo) + loginText.text = String.format( + getString(R.string.direct_login_text), + loginUrlInfo.loginName, + loginUrlInfo.server + ) + } catch (_: IllegalArgumentException) { + DisplayUtils.showSnackMessage(this, R.string.direct_login_failed) + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/authentication/EnforcedServer.kt b/app/src/main/java/com/owncloud/android/authentication/EnforcedServer.kt new file mode 100644 index 000000000000..b9483d2112c6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/EnforcedServer.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.authentication + +data class EnforcedServer(val name: String, val url: String) diff --git a/app/src/main/java/com/owncloud/android/authentication/LoginUrlInfo.kt b/app/src/main/java/com/owncloud/android/authentication/LoginUrlInfo.kt new file mode 100644 index 000000000000..a752d40556c6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/LoginUrlInfo.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 Nextcloud + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.authentication + +import com.nextcloud.model.HTTPStatusCodes + +data class LoginUrlInfo(var server: String, var loginName: String, var appPassword: String) { + fun isValid(status: Int): Boolean = ( + status == HTTPStatusCodes.SUCCESS.code && + server.isNotEmpty() && + loginName.isNotEmpty() && + appPassword.isNotEmpty() + ) +} diff --git a/app/src/main/java/com/owncloud/android/authentication/PassCodeManager.kt b/app/src/main/java/com/owncloud/android/authentication/PassCodeManager.kt new file mode 100644 index 000000000000..c5b2dbc3446a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/PassCodeManager.kt @@ -0,0 +1,135 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2022-2023 Álvaro Brey + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.authentication + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import android.view.View +import android.view.WindowManager +import androidx.annotation.VisibleForTesting +import com.nextcloud.client.core.Clock +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.MainApp +import com.owncloud.android.ui.activity.PassCodeActivity +import com.owncloud.android.ui.activity.RequestCredentialsActivity +import com.owncloud.android.ui.activity.SettingsActivity +import com.owncloud.android.utils.DeviceCredentialUtils +import kotlin.math.abs + +@Suppress("TooManyFunctions") +class PassCodeManager(private val preferences: AppPreferences, private val clock: Clock) { + companion object { + private val exemptOfPasscodeActivities = setOf( + PassCodeActivity::class.java, + RequestCredentialsActivity::class.java + ) + const val PASSCODE_ACTIVITY = 9999 + + /** + * Keeping a "low" positive value is the easiest way to prevent + * the pass code being requested on screen rotations. + */ + private const val PASS_CODE_TIMEOUT = 5000 + + fun setSecureFlag(activity: Activity, isSet: Boolean) { + activity.window?.let { window -> + if (isSet) { + println("flag added") + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + println("flag cleared") + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + } + + var canAskPin = true + private var askPinWhenDeviceLocked = false + + private fun isExemptActivity(activity: Activity): Boolean = exemptOfPasscodeActivities.contains(activity.javaClass) + + fun onActivityResumed(activity: Activity): Boolean { + var askedForPin = false + val timestamp = preferences.lockTimestamp + + if (!isExemptActivity(activity)) { + val passcodeRequested = passCodeShouldBeRequested(timestamp) + val credentialsRequested = deviceCredentialsShouldBeRequested(timestamp, activity) + val shouldHideView = passcodeRequested || credentialsRequested + getActivityRootView(activity)?.visibility = if (shouldHideView) View.GONE else View.VISIBLE + askedForPin = shouldHideView + + if (passcodeRequested) { + requestPasscode(activity) + } else if (credentialsRequested) { + requestCredentials(activity) + } + if (askedForPin) { + preferences.lockTimestamp = 0 + } + } + + if ((!askedForPin && preferences.lockTimestamp != 0L) || askPinWhenDeviceLocked) { + updateLockTimestamp() + askPinWhenDeviceLocked = false + } + + return askedForPin + } + + private fun requestPasscode(activity: Activity) { + val i = Intent(MainApp.getAppContext(), PassCodeActivity::class.java).apply { + action = PassCodeActivity.ACTION_CHECK + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + activity.startActivityForResult(i, PASSCODE_ACTIVITY) + } + + private fun requestCredentials(activity: Activity) { + val i = Intent(MainApp.getAppContext(), RequestCredentialsActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + activity.startActivityForResult(i, PASSCODE_ACTIVITY) + } + + fun onActivityStopped(activity: Activity) { + val powerMgr = activity.getSystemService(Context.POWER_SERVICE) as PowerManager + if ((isPassCodeEnabled() || deviceCredentialsAreEnabled(activity)) && !powerMgr.isInteractive) { + askPinWhenDeviceLocked = true + } + } + + fun updateLockTimestamp() { + preferences.lockTimestamp = clock.millisSinceBoot + canAskPin = false + } + + /** + * `true` if the time elapsed since last unlock is longer than [PASS_CODE_TIMEOUT] and no activities are visible + */ + private fun shouldBeLocked(timestamp: Long): Boolean = + (abs(clock.millisSinceBoot - timestamp) > PASS_CODE_TIMEOUT && canAskPin) || askPinWhenDeviceLocked + + @VisibleForTesting + fun passCodeShouldBeRequested(timestamp: Long): Boolean = shouldBeLocked(timestamp) && isPassCodeEnabled() + + private fun isPassCodeEnabled(): Boolean = SettingsActivity.LOCK_PASSCODE == preferences.lockPreference + + private fun deviceCredentialsShouldBeRequested(timestamp: Long, activity: Activity): Boolean = + shouldBeLocked(timestamp) && deviceCredentialsAreEnabled(activity) + + private fun deviceCredentialsAreEnabled(activity: Activity): Boolean = + (preferences.lockPreference == SettingsActivity.LOCK_DEVICE_CREDENTIALS) && + DeviceCredentialUtils.areCredentialsAvailable(activity) + + private fun getActivityRootView(activity: Activity): View? = activity.window?.findViewById(android.R.id.content) + ?: activity.window?.decorView?.findViewById(android.R.id.content) +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ArbitraryDataProvider.kt b/app/src/main/java/com/owncloud/android/datamodel/ArbitraryDataProvider.kt new file mode 100644 index 000000000000..c7b40aa78830 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/ArbitraryDataProvider.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import com.nextcloud.client.account.User + +@Suppress("Detekt.TooManyFunctions") // legacy interface, will get rid of `accountName` methods in the future +interface ArbitraryDataProvider { + fun deleteKeyForAccount(account: String, key: String) + + fun storeOrUpdateKeyValue(accountName: String, key: String, newValue: Long) + + fun incrementValue(accountName: String, key: String) + fun storeOrUpdateKeyValue(accountName: String, key: String, newValue: Boolean) + fun storeOrUpdateKeyValue(accountName: String, key: String, newValue: String) + fun storeOrUpdateKeyValue(user: User, key: String, newValue: String) + + fun getLongValue(accountName: String, key: String): Long + fun getLongValue(user: User, key: String): Long + fun getBooleanValue(accountName: String, key: String): Boolean + fun getBooleanValue(user: User, key: String): Boolean + fun getIntegerValue(accountName: String, key: String): Int + fun getValue(user: User?, key: String): String + fun getValue(accountName: String, key: String): String + + companion object { + const val DIRECT_EDITING = "DIRECT_EDITING" + const val DIRECT_EDITING_ETAG = "DIRECT_EDITING_ETAG" + const val PREDEFINED_STATUS = "PREDEFINED_STATUS" + const val PUBLIC_KEY = "PUBLIC_KEY_" + const val E2E_ERRORS = "E2E_ERRORS" + const val E2E_ERRORS_TIMESTAMP = "E2E_ERRORS_TIMESTAMP" + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ArbitraryDataProviderImpl.java b/app/src/main/java/com/owncloud/android/datamodel/ArbitraryDataProviderImpl.java new file mode 100644 index 000000000000..1acd7f3c1bd0 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/ArbitraryDataProviderImpl.java @@ -0,0 +1,155 @@ +/* + * Nextcloud Android client application + * + * Copyright (C) 2017 Tobias Kaminsky + * Copyright (C) 2017 Mario Danic + * Copyright (C) 2017 Nextcloud. + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel; + +import android.content.Context; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.database.NextcloudDatabase; +import com.nextcloud.client.database.dao.ArbitraryDataDao; +import com.nextcloud.client.database.entity.ArbitraryDataEntity; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Database provider for handling the persistence aspects of arbitrary data table. + *

+ * Don't instantiate this class, inject the interface instead. + */ +public class ArbitraryDataProviderImpl implements ArbitraryDataProvider { + + private static final String TRUE = "true"; + + private final ArbitraryDataDao arbitraryDataDao; + + /** + * @deprecated inject interface instead + */ + @Deprecated + public ArbitraryDataProviderImpl(final Context context) { + this(NextcloudDatabase.getInstance(context).arbitraryDataDao()); + } + + public ArbitraryDataProviderImpl(@NonNull final ArbitraryDataDao dao) { + this.arbitraryDataDao = dao; + } + + @Override + public void deleteKeyForAccount(@NonNull String account, @NonNull String key) { + arbitraryDataDao.deleteValue(account, key); + } + + @Override + public void storeOrUpdateKeyValue(@NonNull String accountName, @NonNull String key, long newValue) { + storeOrUpdateKeyValue(accountName, key, String.valueOf(newValue)); + } + + @Override + public void incrementValue(@NonNull String accountName, @NonNull String key) { + int oldValue = getIntegerValue(accountName, key); + + int value = 1; + if (oldValue > 0) { + value = oldValue + 1; + } + storeOrUpdateKeyValue(accountName, key, value); + } + + @Override + public void storeOrUpdateKeyValue(@NonNull final String accountName, @NonNull final String key, final boolean newValue) { + storeOrUpdateKeyValue(accountName, key, String.valueOf(newValue)); + } + + @Override + public void storeOrUpdateKeyValue(@NonNull String accountName, + @NonNull String key, + @Nullable String newValue) { + final ArbitraryDataEntity currentValue = arbitraryDataDao.getByAccountAndKey(accountName, key); + if (currentValue != null) { + arbitraryDataDao.updateValue(accountName, key, newValue); + } else { + arbitraryDataDao.insertValue(accountName, key, newValue); + } + } + + @Override + public void storeOrUpdateKeyValue(@NonNull User user, + @NonNull String key, + @NonNull String newValue) { + storeOrUpdateKeyValue(user.getAccountName(), key, newValue); + } + + @Override + public long getLongValue(@NonNull String accountName, @NonNull String key) { + String value = getValue(accountName, key); + + if (value.isEmpty()) { + return -1L; + } else { + return Long.parseLong(value); + } + } + + + @Override + public long getLongValue(User user, @NonNull String key) { + return getLongValue(user.getAccountName(), key); + } + + @Override + public boolean getBooleanValue(@NonNull String accountName, @NonNull String key) { + return TRUE.equalsIgnoreCase(getValue(accountName, key)); + } + + @Override + public boolean getBooleanValue(User user, @NonNull String key) { + return getBooleanValue(user.getAccountName(), key); + } + + /** + * returns integer if found else -1 + * + * @param accountName name of account + * @param key key to get value for + * @return Integer specified by account and key + */ + @Override + public int getIntegerValue(@NonNull String accountName, @NonNull String key) { + String value = getValue(accountName, key); + + if (value.isEmpty()) { + return -1; + } else { + return Integer.parseInt(value); + } + } + + /** + * Returns stored value as string or empty string + * + * @return string if value found or empty string + */ + @Override + @NonNull + public String getValue(@Nullable User user, @NonNull String key) { + return user != null ? getValue(user.getAccountName(), key) : ""; + } + + @Override + @NonNull + public String getValue(@NonNull String accountName, @NonNull String key) { + final ArbitraryDataEntity entity = arbitraryDataDao.getByAccountAndKey(accountName, key); + if (entity == null || entity.getValue() == null) { + return ""; + } + return entity.getValue(); + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ContentResolverHelper.kt b/app/src/main/java/com/owncloud/android/datamodel/ContentResolverHelper.kt new file mode 100644 index 000000000000..3a8866733c48 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/ContentResolverHelper.kt @@ -0,0 +1,103 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Álvaro Brey + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.CancellationSignal +import androidx.annotation.RequiresApi + +object ContentResolverHelper { + const val SORT_DIRECTION_ASCENDING = "ASC" + const val SORT_DIRECTION_DESCENDING = "DESC" + + /** + * Queries the content resolver with the given params using the correct API level-dependant syntax. + * This is needed in order to use LIMIT or OFFSET from android 11. + */ + @JvmStatic + @JvmOverloads + @Suppress("LongParameterList") + fun queryResolver( + contentResolver: ContentResolver, + uri: Uri, + projection: Array, + selection: String? = null, + cancellationSignal: CancellationSignal? = null, + sortColumn: String? = null, + sortDirection: String? = null, + limit: Int? = null + ): Cursor? { + require(!(sortColumn != null && sortDirection == null)) { + "Sort direction is mandatory if sort column is provided" + } + require( + listOf(null, SORT_DIRECTION_ASCENDING, SORT_DIRECTION_DESCENDING).contains(sortDirection) + ) { + "Invalid sort direction" + } + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + val queryArgs = getQueryArgsBundle(selection, sortColumn, sortDirection, limit) + contentResolver.query(uri, projection, queryArgs, cancellationSignal) + } + + else -> { + val sortOrder = getSortOrderString(sortColumn, sortDirection, limit) + contentResolver.query( + uri, + projection, + selection, + null, + sortOrder, + cancellationSignal + ) + } + } + } + + private fun getSortOrderString(sortColumn: String?, sortDirection: String?, limit: Int?): String { + val sortOrderBuilder = StringBuilder() + if (sortColumn != null) { + sortOrderBuilder.append("$sortColumn $sortDirection") + } + if (limit != null) { + if (sortOrderBuilder.isNotEmpty()) { + sortOrderBuilder.append(" ") + } + sortOrderBuilder.append("LIMIT $limit") + } + return sortOrderBuilder.toString() + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getQueryArgsBundle( + selection: String?, + sortColumn: String?, + sortDirection: String?, + limit: Int? + ): Bundle = Bundle().apply { + if (selection != null) { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection) + } + if (sortColumn != null) { + putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(sortColumn)) + val direction = when (sortDirection) { + SORT_DIRECTION_ASCENDING -> ContentResolver.QUERY_SORT_DIRECTION_ASCENDING + else -> ContentResolver.QUERY_SORT_DIRECTION_DESCENDING + } + putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, direction) + } + if (limit != null) { + putInt(ContentResolver.QUERY_ARG_LIMIT, limit) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/DecryptedFolderMetadataOld.java b/app/src/main/java/com/owncloud/android/datamodel/DecryptedFolderMetadataOld.java new file mode 100644 index 000000000000..98712fb59320 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/DecryptedFolderMetadataOld.java @@ -0,0 +1,198 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.datamodel; + +import java.util.HashMap; +import java.util.Map; + +import androidx.annotation.VisibleForTesting; + +/** + * Decrypted class representation of metadata json of folder metadata. + */ +public class DecryptedFolderMetadataOld { + private Metadata metadata; + private Map files; + + private Map filedrop; + + public DecryptedFolderMetadataOld() { + this.metadata = new Metadata(); + this.files = new HashMap<>(); + } + + public DecryptedFolderMetadataOld(Metadata metadata, Map files) { + this.metadata = metadata; + this.files = files; + } + + public Metadata getMetadata() { + return this.metadata; + } + + public Map getFiles() { + return this.files; + } + + public void setMetadata(Metadata metadata) { + this.metadata = metadata; + } + + public void setFiles(Map files) { + this.files = files; + } + + @VisibleForTesting + public void setFiledrop(Map filedrop) { + this.filedrop = filedrop; + } + + public Map getFiledrop() { + return filedrop; + } + + public static class Metadata { + transient + private Map metadataKeys; // outdated with v1.1 + private String metadataKey; + private String checksum; + private double version = 1.2; + + @Override + public String toString() { + return String.valueOf(version); + } + + public Map getMetadataKeys() { + return this.metadataKeys; + } + + public double getVersion() { + return this.version; + } + + public void setMetadataKeys(Map metadataKeys) { + this.metadataKeys = metadataKeys; + } + + public void setVersion(double version) { + this.version = version; + } + + public String getMetadataKey() { + if (metadataKey == null) { + // fallback to old keys array + return metadataKeys.get(0); + } + return metadataKey; + } + + public void setMetadataKey(String metadataKey) { + this.metadataKey = metadataKey; + } + + public String getChecksum() { + return checksum; + } + + public void setChecksum(String checksum) { + this.checksum = checksum; + } + } + + public static class Encrypted { + private Map metadataKeys; + + public Map getMetadataKeys() { + return this.metadataKeys; + } + + public void setMetadataKeys(Map metadataKeys) { + this.metadataKeys = metadataKeys; + } + } + + public static class DecryptedFile { + private Data encrypted; + private String initializationVector; + private String authenticationTag; + transient private int metadataKey; + + public Data getEncrypted() { + return this.encrypted; + } + + public String getInitializationVector() { + return this.initializationVector; + } + + public String getAuthenticationTag() { + return this.authenticationTag; + } + + public int getMetadataKey() { + return this.metadataKey; + } + + public void setEncrypted(Data encrypted) { + this.encrypted = encrypted; + } + + public void setInitializationVector(String initializationVector) { + this.initializationVector = initializationVector; + } + + public void setAuthenticationTag(String authenticationTag) { + this.authenticationTag = authenticationTag; + } + + public void setMetadataKey(int metadataKey) { + this.metadataKey = metadataKey; + } + } + + public static class Data { + private String key; + private String filename; + private String mimetype; + transient private double version; + + public String getKey() { + return this.key; + } + + public String getFilename() { + return this.filename; + } + + public String getMimetype() { + return this.mimetype; + } + + public double getVersion() { + return this.version; + } + + public void setKey(String key) { + this.key = key; + } + + public void setFilename(String filename) { + this.filename = filename; + } + + public void setMimetype(String mimetype) { + this.mimetype = mimetype; + } + + public void setVersion(int version) { + this.version = version; + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/DecryptedPushMessage.kt b/app/src/main/java/com/owncloud/android/datamodel/DecryptedPushMessage.kt new file mode 100644 index 000000000000..301a14914567 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/DecryptedPushMessage.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Unpublished + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +/* + * Push data from server, https://github.com/nextcloud/notifications/blob/master/docs/push-v2.md#encrypted-subject-data + */ +@Parcelize +data class DecryptedPushMessage( + val app: String, + val type: String, + val subject: String, + val id: String, + val nid: Int, + val delete: Boolean, + @SerializedName("delete-all") + val deleteAll: Boolean +) : Parcelable diff --git a/app/src/main/java/com/owncloud/android/datamodel/EncryptedFiledrop.kt b/app/src/main/java/com/owncloud/android/datamodel/EncryptedFiledrop.kt new file mode 100644 index 000000000000..9d035aae0560 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/EncryptedFiledrop.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +data class EncryptedFiledrop( + val encrypted: String, + val initializationVector: String, + val authenticationTag: String, + val encryptedKey: String, + val encryptedTag: String, + val encryptedInitializationVector: String +) diff --git a/app/src/main/java/com/owncloud/android/datamodel/ExternalLinksProvider.kt b/app/src/main/java/com/owncloud/android/datamodel/ExternalLinksProvider.kt new file mode 100644 index 000000000000..635844c6cfa3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/ExternalLinksProvider.kt @@ -0,0 +1,108 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import android.content.ContentResolver +import android.content.ContentValues +import android.database.Cursor +import com.owncloud.android.db.ProviderMeta +import com.owncloud.android.lib.common.ExternalLink +import com.owncloud.android.lib.common.ExternalLinkType +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ExternalLinksProvider(private val contentResolver: ContentResolver) { + private val ioScope = CoroutineScope(Dispatchers.IO) + + fun storeExternalLink(externalLink: ExternalLink) { + ioScope.launch { + Log_OC.v(TAG, "Adding " + externalLink.name) + val cv = createContentValuesFromExternalLink(externalLink) + contentResolver.insert(ProviderMeta.ProviderTableMeta.CONTENT_URI_EXTERNAL_LINKS, cv) + ?: Log_OC.e(TAG, "Failed to insert ${externalLink.name} into external link db.") + } + } + + fun deleteAllExternalLinks() { + ioScope.launch { + contentResolver.delete( + ProviderMeta.ProviderTableMeta.CONTENT_URI_EXTERNAL_LINKS, + null, + null + ) + } + } + + fun getExternalLink(type: ExternalLinkType, onComplete: (List) -> Unit) { + ioScope.launch { + val cursor = contentResolver.query( + ProviderMeta.ProviderTableMeta.CONTENT_URI_EXTERNAL_LINKS, + null, + "type = ?", + arrayOf(type.toString()), + null + ) ?: run { + Log_OC.e(TAG, "DB error restoring externalLinks.") + withContext(Dispatchers.Main) { + onComplete(emptyList()) + } + return@launch + } + + val result = cursor.use { c -> + generateSequence { if (c.moveToNext()) c else null } + .map { createExternalLinkFromCursor(it) } + .toList() + } + + return@launch withContext(Dispatchers.Main) { + onComplete(result) + } + } + } + + private fun createContentValuesFromExternalLink(externalLink: ExternalLink): ContentValues = ContentValues().apply { + put(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_ICON_URL, externalLink.iconUrl) + put(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_LANGUAGE, externalLink.language) + put(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_TYPE, externalLink.type.toString()) + put(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_NAME, externalLink.name) + put(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_URL, externalLink.url) + put(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_REDIRECT, externalLink.redirect) + } + + private fun createExternalLinkFromCursor(cursor: Cursor): ExternalLink { + fun col(name: String) = cursor.getColumnIndexOrThrow(name) + val typeStr = cursor.getString(col(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_TYPE)) + + return ExternalLink( + id = cursor.getInt(col(ProviderMeta.ProviderTableMeta._ID)), + iconUrl = cursor.getString(col(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_ICON_URL)), + language = cursor.getString(col(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_LANGUAGE)), + type = when (typeStr) { + "link" -> ExternalLinkType.LINK + "settings" -> ExternalLinkType.SETTINGS + "quota" -> ExternalLinkType.QUOTA + else -> ExternalLinkType.UNKNOWN + }, + name = cursor.getString(col(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_NAME)), + url = cursor.getString(col(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_URL)), + redirect = cursor.getInt(col(ProviderMeta.ProviderTableMeta.EXTERNAL_LINKS_REDIRECT)) == 1 + ) + } + + fun cleanup() { + ioScope.cancel() + } + + companion object { + private val TAG: String = ExternalLinksProvider::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java new file mode 100644 index 000000000000..741b1dd8f673 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -0,0 +1,2885 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2022-2025 TSI-mc + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-FileCopyrightText: 2011 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.datamodel; + +import android.annotation.SuppressLint; +import android.content.ContentProviderClient; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.media.MediaScannerConnection; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.MediaStore; +import android.text.TextUtils; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.nextcloud.android.lib.resources.files.FileDownloadLimit; +import com.nextcloud.client.account.User; +import com.nextcloud.client.database.NextcloudDatabase; +import com.nextcloud.client.database.dao.CapabilityDao; +import com.nextcloud.client.database.dao.FileDao; +import com.nextcloud.client.database.dao.OfflineOperationDao; +import com.nextcloud.client.database.dao.RecommendedFileDao; +import com.nextcloud.client.database.dao.ShareDao; +import com.nextcloud.client.database.entity.FileEntity; +import com.nextcloud.client.database.entity.OfflineOperationEntity; +import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepository; +import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepositoryType; +import com.nextcloud.model.OfflineOperationRawType; +import com.nextcloud.model.OfflineOperationType; +import com.nextcloud.model.ShareeEntry; +import com.nextcloud.utils.date.DateFormatPattern; +import com.nextcloud.utils.extensions.DateExtensionsKt; +import com.nextcloud.utils.extensions.FileExtensionsKt; +import com.nextcloud.utils.extensions.StringExtensionsKt; +import com.owncloud.android.MainApp; +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta; +import com.owncloud.android.lib.common.network.WebdavEntry; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation; +import com.owncloud.android.lib.resources.files.model.FileLockType; +import com.owncloud.android.lib.resources.files.model.GeoLocation; +import com.owncloud.android.lib.resources.files.model.ImageDimension; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.lib.resources.files.model.ServerFileInterface; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.lib.resources.shares.ShareeUser; +import com.owncloud.android.lib.resources.status.CapabilityBooleanType; +import com.owncloud.android.lib.resources.status.E2EVersion; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.lib.resources.tags.Tag; +import com.owncloud.android.operations.RemoteOperationFailedException; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.MimeType; +import com.owncloud.android.utils.MimeTypeUtil; + +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import kotlin.Pair; + +@SuppressFBWarnings("CE") +public class FileDataStorageManager { + private static final String TAG = FileDataStorageManager.class.getSimpleName(); + + private static final String AND = " = ? AND "; + private static final String FAILED_TO_INSERT_MSG = "Fail to insert insert file to database "; + private static final String SENDING_TO_FILECONTENTPROVIDER_MSG = "Sending %d operations to FileContentProvider"; + private static final String EXCEPTION_MSG = "Exception in batch of operations "; + + public static final int ROOT_PARENT_ID = 0; + private static final String JSON_NULL_STRING = "null"; + private static final String JSON_EMPTY_ARRAY = "[]"; + + private final ContentResolver contentResolver; + private final ContentProviderClient contentProviderClient; + private final User user; + + public final RecommendedFileDao recommendedFileDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).recommendedFileDao(); + public final OfflineOperationDao offlineOperationDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).offlineOperationDao(); + public final FileDao fileDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).fileDao(); + public final ShareDao shareDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).shareDao(); + public final CapabilityDao capabilityDao = NextcloudDatabase.instance().capabilityDao(); + + private final Gson gson = new Gson(); + public final OfflineOperationsRepositoryType offlineOperationsRepository; + private final static int DEFAULT_CURSOR_INT_VALUE = -1; + + public FileDataStorageManager(User user, ContentResolver contentResolver) { + this.contentProviderClient = null; + this.contentResolver = contentResolver; + this.user = user; + offlineOperationsRepository = new OfflineOperationsRepository(this); + } + + public FileDataStorageManager(User user, ContentProviderClient contentProviderClient) { + this.contentProviderClient = contentProviderClient; + this.contentResolver = null; + this.user = user; + offlineOperationsRepository = new OfflineOperationsRepository(this); + } + + /** + * Use getFileByEncryptedRemotePath() or getFileByDecryptedRemotePath() + */ + @Deprecated + public OCFile getFileByPath(String path) { + return getFileByEncryptedRemotePath(path); + } + + public OCFile getFileByEncryptedRemotePath(String path) { + return getFileByPath(ProviderTableMeta.FILE_PATH, path); + } + + public @Nullable + OCFile getFileByDecryptedRemotePath(String path) { + return getFileByPath(ProviderTableMeta.FILE_PATH_DECRYPTED, path); + } + + /** + * Returns the {@link OCFile} for the given remote path. + * Tries the path as-is first; if not found, appends a trailing "/" for folders. + * + * @param path The file or folder path. + * @return The matching {@link OCFile}, or null if not found. + */ + @Nullable + public OCFile getFileByRemotePath(String path) { + OCFile file = getFileByDecryptedRemotePath(path); + + if (file == null) { + file = getFileByDecryptedRemotePath(path + OCFile.PATH_SEPARATOR); + } + + return file; + } + + public void addCreateFileOfflineOperation(String[] localPaths, String[] remotePaths) { + if (localPaths.length != remotePaths.length) { + Log_OC.d(TAG, "Local path and remote path size do not match"); + return; + } + + for (int i = 0; i < localPaths.length; i++) { + String localPath = localPaths[i]; + String remotePath = remotePaths[i]; + String mimeType = MimeTypeUtil.getMimeTypeFromPath(remotePath); + + OfflineOperationEntity entity = new OfflineOperationEntity(); + entity.setPath(remotePath); + entity.setType(new OfflineOperationType.CreateFile(OfflineOperationRawType.CreateFile.name(), localPath, remotePath, mimeType)); + + long createdAt = System.currentTimeMillis(); + long modificationTimestamp = System.currentTimeMillis(); + + entity.setCreatedAt(createdAt); + entity.setModifiedAt(modificationTimestamp / 1000); + entity.setFilename(new File(remotePath).getName()); + + String parentPath = new File(remotePath).getParent() + OCFile.PATH_SEPARATOR; + OCFile parentFile = getFileByDecryptedRemotePath(parentPath); + + if (parentFile != null) { + entity.setParentOCFileId(parentFile.getFileId()); + } + + offlineOperationDao.insert(entity); + createPendingFile(remotePath, mimeType, createdAt, modificationTimestamp); + } + } + + public OfflineOperationEntity getOfflineEntityFromOCFile(OCFile file) { + return offlineOperationDao.getByPath(file.getDecryptedRemotePath()); + } + + public OfflineOperationEntity addCreateFolderOfflineOperation(String path, String filename, Long parentOCFileId) { + OfflineOperationEntity entity = new OfflineOperationEntity(); + + entity.setFilename(filename); + entity.setParentOCFileId(parentOCFileId); + + OfflineOperationType.CreateFolder operationType = new OfflineOperationType.CreateFolder(OfflineOperationRawType.CreateFolder.name(), path); + entity.setType(operationType); + entity.setPath(path); + + long createdAt = System.currentTimeMillis(); + long modificationTimestamp = System.currentTimeMillis(); + + entity.setCreatedAt(createdAt); + entity.setModifiedAt(modificationTimestamp / 1000); + + offlineOperationDao.insert(entity); + createPendingDirectory(path, createdAt, modificationTimestamp); + + return entity; + } + + public void createPendingFile(String path, String mimeType, long createdAt, long modificationTimestamp) { + OCFile file = new OCFile(path); + file.setMimeType(mimeType); + file.setCreationTimestamp(createdAt); + file.setModificationTimestamp(modificationTimestamp); + saveFileWithParent(file, MainApp.getAppContext()); + } + + public void createPendingDirectory(String path, long createdAt, long modificationTimestamp) { + OCFile directory = new OCFile(path); + directory.setMimeType(MimeType.DIRECTORY); + directory.setCreationTimestamp(createdAt); + directory.setModificationTimestamp(modificationTimestamp); + saveFileWithParent(directory, MainApp.getAppContext()); + } + + public void deleteOfflineOperation(OCFile file) { + offlineOperationsRepository.deleteOperation(file); + } + + public void addRenameFileOfflineOperation(OCFile file, String newName) { + OfflineOperationEntity entity = new OfflineOperationEntity(); + + entity.setFilename(newName); + entity.setParentOCFileId(file.getParentId()); + + OfflineOperationType operationType = new OfflineOperationType.RenameFile(OfflineOperationRawType.RenameFile.name(), file.getFileId(), newName); + entity.setType(operationType); + entity.setPath(file.getDecryptedRemotePath()); + + long createdAt = System.currentTimeMillis(); + long modificationTimestamp = System.currentTimeMillis(); + + entity.setCreatedAt(createdAt); + entity.setModifiedAt(modificationTimestamp / 1000); + + offlineOperationDao.insert(entity); + } + + public String getFileNameBasedOnEncryptionStatus(OCFile file) { + FileEntity entity = fileDao.getFileById(file.getFileId()); + if (entity == null) { + return file.getFileName(); + } + + if (file.isEncrypted()) { + return entity.getEncryptedName(); + } else { + return entity.getName(); + } + } + + public String getFilenameConsideringOfflineOperation(OCFile file) { + String filename = file.getDecryptedFileName(); + OfflineOperationEntity renameEntity = offlineOperationDao.getByPath(file.getDecryptedRemotePath()); + if (renameEntity != null && renameEntity.getType() instanceof OfflineOperationType.RenameFile renameFile) { + filename = renameFile.getNewName(); + } + + return filename; + } + + public void addRemoveFileOfflineOperation(@NonNull OCFile file) { + OfflineOperationEntity entity = new OfflineOperationEntity(); + + String path = file.getDecryptedRemotePath(); + entity.setFilename(file.getFileName()); + entity.setParentOCFileId(file.getParentId()); + + OfflineOperationType.RemoveFile operationType = new OfflineOperationType.RemoveFile(OfflineOperationRawType.RemoveFile.name(), path); + entity.setType(operationType); + entity.setPath(path); + + long createdAt = System.currentTimeMillis(); + long modificationTimestamp = System.currentTimeMillis(); + + entity.setCreatedAt(createdAt); + entity.setModifiedAt(modificationTimestamp / 1000); + + offlineOperationDao.insert(entity); + } + + public void renameOfflineOperation(OCFile file, String newFolderName) { + var entity = offlineOperationDao.getByPath(file.getDecryptedRemotePath()); + if (entity == null) { + return; + } + + OCFile parentFolder = getFileById(file.getParentId()); + if (parentFolder == null) { + return; + } + + String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + OCFile.PATH_SEPARATOR; + + if (entity.getType() instanceof OfflineOperationType.CreateFolder createFolderType) { + createFolderType.setPath(newPath); + } else if (entity.getType() instanceof OfflineOperationType.CreateFile createFileType) { + createFileType.setRemotePath(newPath); + createFileType.setMimeType(file.getMimeType()); + } + entity.setType(entity.getType()); + + entity.setPath(newPath); + entity.setFilename(newFolderName); + offlineOperationDao.update(entity); + + moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath()); + } + + @SuppressLint("SimpleDateFormat") + public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCFile file) { + if (file == null) return; + + String oldFileName = entity.getFilename(); + if (oldFileName == null) return; + + Long parentOCFileId = entity.getParentOCFileId(); + if (parentOCFileId == null) return; + + OCFile parentFolder = getFileById(parentOCFileId); + if (parentFolder == null) return; + + DateFormatPattern formatPattern = DateFormatPattern.FullDateWithHours; + String currentDateTime = DateExtensionsKt.currentDateRepresentation(new Date(), formatPattern); + + String newFolderName = oldFileName + " - " + currentDateTime; + String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + OCFile.PATH_SEPARATOR; + moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath()); + offlineOperationsRepository.updateNextOperations(entity); + } + + private @Nullable + OCFile getFileByPath(String type, String path) { + final boolean shouldUseEncryptedPath = ProviderTableMeta.FILE_PATH.equals(type); + FileEntity fileEntity = shouldUseEncryptedPath ? + fileDao.getFileByEncryptedRemotePath(path, user.getAccountName()) : + fileDao.getFileByDecryptedRemotePath(path, user.getAccountName()); + + if (fileEntity != null) { + return createFileInstance(fileEntity); + } + + if (OCFile.ROOT_PATH.equals(path)) { + return createRootDir(); // root should always exist + } + + return null; + } + + public @Nullable + OCFile getFileById(long id) { + FileEntity fileEntity = fileDao.getFileById(id); + if (fileEntity != null) { + return createFileInstance(fileEntity); + } + return null; + } + + public @Nullable + OCFile getFileByLocalId(long localId) { + FileEntity fileEntity = fileDao.getFileByLocalId(localId); + if (fileEntity != null) { + return createFileInstance(fileEntity); + } + return null; + } + + public @Nullable + OCFile getFileByLocalPath(String path) { + FileEntity fileEntity = fileDao.getFileByLocalPath(path, user.getAccountName()); + if (fileEntity != null) { + return createFileInstance(fileEntity); + } + return null; + } + + public @Nullable + OCFile getFileByRemoteId(String remoteId) { + FileEntity fileEntity = fileDao.getFileByRemoteId(remoteId, user.getAccountName()); + if (fileEntity != null) { + return createFileInstance(fileEntity); + } + return null; + } + + public boolean fileExists(long id) { + return fileDao.getFileById(id) != null; + } + + public boolean fileExists(String path) { + return fileDao.getFileByEncryptedRemotePath(path, user.getAccountName()) != null; + } + + public OCFile getTopParent(OCFile file) { + long topParentId = getTopParentId(file); + return getFileById(topParentId); + } + + public long getTopParentId(OCFile file) { + if (file.getParentId() == 1) { + return file.getFileId(); + } + + return getTopParentIdRecursive(file); + } + + private long getTopParentIdRecursive(OCFile file) { + if (file.getParentId() == 1) { + return file.getFileId(); + } + + OCFile parentFile = getFileById(file.getParentId()); + if (parentFile != null) { + return getTopParentId(parentFile); + } + + return file.getFileId(); + } + + public List getAllFilesRecursivelyInsideFolder(OCFile file) { + ArrayList result = new ArrayList<>(); + + if (file == null || !file.fileExists()) { + return result; + } + + if (!file.isFolder()) { + if (!file.isAPKorAAB()) { + result.add(file); + } + return result; + } + + List filesInsideFolder = getFolderContent(file.getFileId(), false); + for (OCFile item: filesInsideFolder) { + if (!item.isFolder() && !item.isAPKorAAB()) { + result.add(item); + } else { + result.addAll(getAllFilesRecursivelyInsideFolder(item)); + } + } + + return result; + } + + public List getFolderContent(OCFile ocFile, boolean onlyOnDevice) { + if (ocFile != null && ocFile.isFolder() && ocFile.fileExists()) { + return getFolderContent(ocFile.getFileId(), onlyOnDevice); + } else { + return new ArrayList<>(); + } + } + + public OCFile findDuplicatedFile(OCFile parentFolder, ServerFileInterface newFile) { + List folderContent = getFolderContent(parentFolder, false); + if (folderContent == null || folderContent.isEmpty()) { + return null; + } + + OCFile duplicatedFile = null; + for (OCFile file : folderContent) { + if (file.getFileName().equals(newFile.getFileName())) { + duplicatedFile = file; + break; + } + } + + return duplicatedFile; + } + + public List getFolderImages(OCFile folder, boolean onlyOnDevice) { + List imageList = new ArrayList<>(); + + if (folder != null) { + // TODO better implementation, filtering in the access to database instead of here + List folderContent = getFolderContent(folder, onlyOnDevice); + + for (OCFile ocFile : folderContent) { + if (MimeTypeUtil.isImage(ocFile)) { + imageList.add(ocFile); + } + } + } + + return imageList; + } + + public boolean saveFile(OCFile ocFile) { + Log_OC.d(TAG, "saving file: " + ocFile.getRemotePath()); + + boolean overridden = false; + final ContentValues cv = createContentValuesForFile(ocFile); + if (ocFile.isFolder()) { + // only refresh folder operation must update eTag otherwise content of the folder may stay as outdated + cv.remove(ProviderTableMeta.FILE_ETAG); + cv.remove(ProviderTableMeta.FILE_STORAGE_PATH); + } + + boolean sameRemotePath = fileExists(ocFile.getRemotePath()); + if (sameRemotePath || + fileExists(ocFile.getFileId())) { // for renamed files; no more delete and create + + if (sameRemotePath) { + OCFile oldFile = getFileByPath(ocFile.getRemotePath()); + ocFile.setFileId(oldFile.getFileId()); + } + + overridden = true; + if (getContentResolver() != null) { + getContentResolver().update(ProviderTableMeta.CONTENT_URI, cv, + ProviderTableMeta._ID + "=?", + new String[]{String.valueOf(ocFile.getFileId())}); + } else { + try { + getContentProviderClient().update(ProviderTableMeta.CONTENT_URI, + cv, ProviderTableMeta._ID + "=?", + new String[]{String.valueOf(ocFile.getFileId())}); + } catch (RemoteException e) { + Log_OC.e(TAG, FAILED_TO_INSERT_MSG + e.getMessage(), e); + } + } + } else { + Uri result_uri = null; + if (getContentResolver() != null) { + result_uri = getContentResolver().insert(ProviderTableMeta.CONTENT_URI_FILE, cv); + } else { + try { + result_uri = getContentProviderClient().insert(ProviderTableMeta.CONTENT_URI_FILE, cv); + } catch (RemoteException e) { + Log_OC.e(TAG, FAILED_TO_INSERT_MSG + e.getMessage(), e); + } + } + if (result_uri != null) { + long new_id = Long.parseLong(result_uri.getPathSegments().get(1)); + ocFile.setFileId(new_id); + } + } + + return overridden; + } + + /** + * Ensures that an {@link OCFile} and all of its parent folders are stored locally. + *

+ * If the file has no parent ID and is not the root folder, this method recursively: + *

    + *
  • Resolves the parent path
  • + *
  • Loads the parent from local storage or fetches it from the server
  • + *
  • Saves all missing parent folders
  • + *
  • Assigns the resolved parent ID to the file
  • + *
+ * + * @param ocFile the file to be saved together with its parent hierarchy + * @param context Android context used for remote operations + * + * @return the same {@link OCFile} instance with a valid parent ID + * + * @throws RemoteOperationFailedException if a parent folder cannot be retrieved + * from the server + */ + public OCFile saveFileWithParent(OCFile ocFile, Context context) { + if (ocFile.getParentId() == 0 && !OCFile.ROOT_PATH.equals(ocFile.getRemotePath())) { + Log_OC.d(TAG, "saving file with parents: " + ocFile.getRemotePath()); + + String remotePath = ocFile.getRemotePath(); + String parentPath = remotePath.substring(0, remotePath.lastIndexOf(ocFile.getFileName())); + + OCFile parentFile = getFileByPath(parentPath); + OCFile returnFile; + + if (parentFile == null) { + Log_OC.d(TAG, "Parent not found locally, fetching: " + parentPath); + + final var operation = new ReadFileRemoteOperation(parentPath); + final var result = operation.execute(getUser(), context); + + if (result.isSuccess() && result.getData().get(0) instanceof RemoteFile remoteFile) { + OCFile folder = FileStorageUtils.fillOCFile(remoteFile); + Log_OC.d(TAG, "Fetched parent folder: " + folder); + returnFile = saveFileWithParent(folder, context); + } else { + Exception exception = result.getException(); + String message = "Error during saving file with parents: " + ocFile.getRemotePath() + " / " + + result.getLogMessage(context); + + Log_OC.e(TAG, message); + + if (exception != null) { + throw new RemoteOperationFailedException(message, exception); + } else { + throw new RemoteOperationFailedException(message); + } + } + } else { + Log_OC.d(TAG, "parent file exists, calling saveFileWithParent: " + ocFile.getRemotePath()); + returnFile = saveFileWithParent(parentFile, context); + } + + long parentId = returnFile.getFileId(); + Log_OC.d(TAG, "saving parent id of: " + ocFile.getRemotePath() + " with: " + parentId); + ocFile.setParentId(parentId); + saveFile(ocFile); + } + + return ocFile; + } + + public static void clearTempEncryptedFolder(String accountName) { + File tempEncryptedFolder = new File(FileStorageUtils.getTemporalEncryptedFolderPath(accountName)); + + if (!tempEncryptedFolder.exists()) { + Log_OC.d(TAG, "tempEncryptedFolder does not exist"); + return; + } + + try { + FileUtils.cleanDirectory(tempEncryptedFolder); + + Log_OC.d(TAG, "tempEncryptedFolder cleared"); + } catch (IOException exception) { + Log_OC.d(TAG, "Error caught at clearTempEncryptedFolder: " + exception); + } + } + + public static File createTempEncryptedFolder(String accountName) { + File tempEncryptedFolder = new File(FileStorageUtils.getTemporalEncryptedFolderPath(accountName)); + + if (!tempEncryptedFolder.exists()) { + boolean isTempEncryptedFolderCreated = tempEncryptedFolder.mkdirs(); + Log_OC.d(TAG, "tempEncryptedFolder created" + isTempEncryptedFolderCreated); + } else { + Log_OC.d(TAG, "tempEncryptedFolder already exists"); + } + + return tempEncryptedFolder; + } + + public void saveNewFile(OCFile newFile) { + String remoteParentPath = new File(newFile.getRemotePath()).getParent(); + remoteParentPath = remoteParentPath.endsWith(OCFile.PATH_SEPARATOR) ? + remoteParentPath : remoteParentPath + OCFile.PATH_SEPARATOR; + OCFile parent = getFileByPath(remoteParentPath); + if (parent != null) { + newFile.setParentId(parent.getFileId()); + saveFile(newFile); + } else { + throw new IllegalArgumentException("Saving a new file in an unexisting folder"); + } + } + + + /** + * Inserts or updates the list of files contained in a given folder. + *

+ * CALLER IS RESPONSIBLE FOR GRANTING RIGHT UPDATE OF INFORMATION, NOT THIS METHOD. HERE ONLY DATA CONSISTENCY + * SHOULD BE GRANTED + * + * @param folder + * @param updatedFiles + * @param filesToRemove + */ + public void saveFolder(OCFile folder, List updatedFiles, Collection filesToRemove) { + Log_OC.d(TAG, "Saving folder " + folder.getRemotePath() + " with " + updatedFiles.size() + + " children and " + filesToRemove.size() + " files to remove"); + + ArrayList operations = new ArrayList<>(updatedFiles.size()); + + // prepare operations to insert or update files to save in the given folder + for (OCFile ocFile : updatedFiles) { + ContentValues contentValues = createContentValuesForFile(ocFile); + contentValues.put(ProviderTableMeta.FILE_PARENT, folder.getFileId()); + + if (fileExists(ocFile.getFileId()) || fileExists(ocFile.getRemotePath())) { + long fileId; + if (ocFile.getFileId() != -1) { + fileId = ocFile.getFileId(); + } else { + fileId = getFileByPath(ocFile.getRemotePath()).getFileId(); + } + // updating an existing file + operations.add(ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI) + .withValues(contentValues) + .withSelection(ProviderTableMeta._ID + " = ?", new String[]{String.valueOf(fileId)}) + .build()); + } else { + // adding a new file + operations.add(ContentProviderOperation.newInsert(ProviderTableMeta.CONTENT_URI) + .withValues(contentValues) + .build()); + } + } + + // prepare operations to remove files in the given folder + String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + ProviderTableMeta.FILE_PATH + " = ?"; + String[] whereArgs = new String[2]; + whereArgs[0] = user.getAccountName(); + for (OCFile ocFile : filesToRemove) { + if (ocFile.getParentId() == folder.getFileId()) { + whereArgs[1] = ocFile.getRemotePath(); + if (ocFile.isFolder()) { + operations.add(ContentProviderOperation.newDelete( + ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, ocFile.getFileId())) + .withSelection(where, whereArgs).build()); + + File localFolder = new File(FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), ocFile)); + if (localFolder.exists()) { + removeLocalFolder(localFolder); + } + } else { + operations.add(ContentProviderOperation.newDelete( + ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_FILE, ocFile.getFileId())) + .withSelection(where, whereArgs).build()); + + if (ocFile.isDown()) { + String path = ocFile.getStoragePath(); + if (new File(path).delete() && MimeTypeUtil.isMedia(ocFile.getMimeType())) { + triggerMediaScan(path, ocFile); // notify MediaScanner about removed file + } + } + } + } + } + + // update metadata of folder + ContentValues contentValues = createContentValuesForFolder(folder); + + operations.add(ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI) + .withValues(contentValues) + .withSelection(ProviderTableMeta._ID + " = ?", new String[]{String.valueOf(folder.getFileId())}) + .build()); + + // apply operations in batch + ContentProviderResult[] results = null; + Log_OC.d(TAG, String.format(Locale.ENGLISH, SENDING_TO_FILECONTENTPROVIDER_MSG, operations.size())); + + try { + if (getContentResolver() != null) { + results = getContentResolver().applyBatch(MainApp.getAuthority(), operations); + + } else { + results = getContentProviderClient().applyBatch(operations); + } + + } catch (OperationApplicationException | RemoteException e) { + Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e); + } + + // update new id in file objects for insertions + if (results != null) { + long newId; + Iterator fileIterator = updatedFiles.iterator(); + OCFile ocFile; + for (ContentProviderResult result : results) { + if (fileIterator.hasNext()) { + ocFile = fileIterator.next(); + } else { + ocFile = null; + } + if (result.uri != null) { + newId = Long.parseLong(result.uri.getPathSegments().get(1)); + if (ocFile != null) { + ocFile.setFileId(newId); + } + } + } + } + } + + /** + * Returns a {@link ContentValues} filled with values that are common to both files and folders + * + * @see #createContentValuesForFile(OCFile) + * @see #createContentValuesForFolder(OCFile) + */ + @SuppressFBWarnings("CE") + private ContentValues createContentValuesBase(OCFile fileOrFolder) { + final ContentValues cv = new ContentValues(); + cv.put(ProviderTableMeta.FILE_MODIFIED, fileOrFolder.getModificationTimestamp()); + cv.put(ProviderTableMeta.FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA, fileOrFolder.getModificationTimestampAtLastSyncForData()); + cv.put(ProviderTableMeta.FILE_PARENT, fileOrFolder.getParentId()); + cv.put(ProviderTableMeta.FILE_UPLOADED, fileOrFolder.getUploadTimestamp()); + cv.put(ProviderTableMeta.FILE_CREATION, fileOrFolder.getCreationTimestamp()); + cv.put(ProviderTableMeta.FILE_CONTENT_TYPE, fileOrFolder.getMimeType()); + cv.put(ProviderTableMeta.FILE_NAME, fileOrFolder.getFileName()); + cv.put(ProviderTableMeta.FILE_PATH, fileOrFolder.getRemotePath()); + cv.put(ProviderTableMeta.FILE_PATH_DECRYPTED, fileOrFolder.getDecryptedRemotePath()); + cv.put(ProviderTableMeta.FILE_ACCOUNT_OWNER, user.getAccountName()); + cv.put(ProviderTableMeta.FILE_IS_ENCRYPTED, fileOrFolder.isEncrypted()); + cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE, fileOrFolder.getLastSyncDateForProperties()); + cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA, fileOrFolder.getLastSyncDateForData()); + cv.put(ProviderTableMeta.FILE_ETAG, fileOrFolder.getEtag()); + cv.put(ProviderTableMeta.FILE_ETAG_ON_SERVER, fileOrFolder.getEtagOnServer()); + cv.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, fileOrFolder.isSharedViaLink() ? 1 : 0); + cv.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, fileOrFolder.isSharedWithSharee() ? 1 : 0); + cv.put(ProviderTableMeta.FILE_PERMISSIONS, fileOrFolder.getPermissions()); + cv.put(ProviderTableMeta.FILE_REMOTE_ID, fileOrFolder.getRemoteId()); + cv.put(ProviderTableMeta.FILE_LOCAL_ID, fileOrFolder.getLocalId()); + cv.put(ProviderTableMeta.FILE_FAVORITE, fileOrFolder.isFavorite()); + cv.put(ProviderTableMeta.FILE_HIDDEN, fileOrFolder.shouldHide()); + cv.put(ProviderTableMeta.FILE_UNREAD_COMMENTS_COUNT, fileOrFolder.getUnreadCommentsCount()); + cv.put(ProviderTableMeta.FILE_OWNER_ID, fileOrFolder.getOwnerId()); + cv.put(ProviderTableMeta.FILE_OWNER_DISPLAY_NAME, fileOrFolder.getOwnerDisplayName()); + cv.put(ProviderTableMeta.FILE_NOTE, fileOrFolder.getNote()); + cv.put(ProviderTableMeta.FILE_SHAREES, gson.toJson(fileOrFolder.getSharees())); + cv.put(ProviderTableMeta.FILE_TAGS, gson.toJson(fileOrFolder.getTags())); + cv.put(ProviderTableMeta.FILE_RICH_WORKSPACE, fileOrFolder.getRichWorkspace()); + cv.put(ProviderTableMeta.FILE_INTERNAL_TWO_WAY_SYNC_TIMESTAMP, fileOrFolder.getInternalFolderSyncTimestamp()); + cv.put(ProviderTableMeta.FILE_INTERNAL_TWO_WAY_SYNC_RESULT, fileOrFolder.getInternalFolderSyncResult()); + return cv; + } + + /** + * Returns a {@link ContentValues} filled with values for a folder + * + * @see #createContentValuesForFile(OCFile) + * @see #createContentValuesBase(OCFile) + */ + private ContentValues createContentValuesForFolder(OCFile folder) { + final ContentValues cv = createContentValuesBase(folder); + cv.put(ProviderTableMeta.FILE_CONTENT_LENGTH, 0); + return cv; + } + + /** + * Returns a {@link ContentValues} filled with values for a file + * + * @see #createContentValuesForFolder(OCFile) + * @see #createContentValuesBase(OCFile) + */ + @SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE") + private ContentValues createContentValuesForFile(OCFile file) { + final ContentValues cv = createContentValuesBase(file); + cv.put(ProviderTableMeta.FILE_CONTENT_LENGTH, file.getFileLength()); + cv.put(ProviderTableMeta.FILE_ENCRYPTED_NAME, file.getEncryptedFileName()); + cv.put(ProviderTableMeta.FILE_STORAGE_PATH, file.getStoragePath()); + cv.put(ProviderTableMeta.FILE_UPDATE_THUMBNAIL, file.isUpdateThumbnailNeeded()); + cv.put(ProviderTableMeta.FILE_IS_DOWNLOADING, file.isDownloading()); + cv.put(ProviderTableMeta.FILE_ETAG_IN_CONFLICT, file.getEtagInConflict()); + cv.put(ProviderTableMeta.FILE_HAS_PREVIEW, file.isPreviewAvailable() ? 1 : 0); + cv.put(ProviderTableMeta.FILE_LOCKED, file.isLocked()); + final FileLockType lockType = file.getLockType(); + cv.put(ProviderTableMeta.FILE_LOCK_TYPE, lockType != null ? lockType.getValue() : -1); + cv.put(ProviderTableMeta.FILE_HIDDEN, file.shouldHide()); + cv.put(ProviderTableMeta.FILE_LOCK_OWNER, file.getLockOwnerId()); + cv.put(ProviderTableMeta.FILE_LOCK_OWNER_DISPLAY_NAME, file.getLockOwnerDisplayName()); + cv.put(ProviderTableMeta.FILE_LOCK_OWNER_EDITOR, file.getLockOwnerEditor()); + cv.put(ProviderTableMeta.FILE_LOCK_TIMESTAMP, file.getLockTimestamp()); + cv.put(ProviderTableMeta.FILE_LOCK_TIMEOUT, file.getLockTimeout()); + cv.put(ProviderTableMeta.FILE_LOCK_TOKEN, file.getLockToken()); + cv.put(ProviderTableMeta.FILE_MODIFIED, file.getModificationTimestamp()); + cv.put(ProviderTableMeta.FILE_METADATA_SIZE, gson.toJson(file.getImageDimension())); + cv.put(ProviderTableMeta.FILE_METADATA_GPS, gson.toJson(file.getGeoLocation())); + cv.put(ProviderTableMeta.FILE_METADATA_LIVE_PHOTO, file.getLinkedFileIdForLivePhoto()); + cv.put(ProviderTableMeta.FILE_E2E_COUNTER, file.getE2eCounter()); + cv.put(ProviderTableMeta.FILE_INTERNAL_TWO_WAY_SYNC_TIMESTAMP, file.getInternalFolderSyncTimestamp()); + cv.put(ProviderTableMeta.FILE_INTERNAL_TWO_WAY_SYNC_RESULT, file.getInternalFolderSyncResult()); + + return cv; + } + + // region remove file/folder + public boolean removeFile(OCFile ocFile, boolean removeDBData, boolean removeLocalCopy) { + if (ocFile == null) { + Log_OC.e(TAG, "oc file is null, cannot delete it"); + return false; + } + + if (ocFile.isFolder()) { + Log_OC.d(TAG, "deleting folder"); + return removeFolder(ocFile, removeDBData, removeLocalCopy); + } + + boolean success = true; + if (removeDBData) { + Log_OC.d(TAG, "deleting db data of file"); + success = fileDao.deleteFileByRemotePath(user.getAccountName(), ocFile.getRemotePath()) > 0; + } + + if (success) { + Log_OC.d(TAG, "deleting local copy of file"); + success = removeLocalCopyIfNeeded(ocFile, removeLocalCopy, removeDBData); + } + + return success; + } + + private boolean removeLocalCopyIfNeeded(OCFile ocFile, boolean removeLocalCopy, boolean removeDBData) { + if (!removeLocalCopy) { + Log_OC.w(TAG, "removeLocalCopyIfNeeded: removeLocalCopy=false"); + return true; + } + + String localPath = ocFile.getStoragePath(); + final var file = FileExtensionsKt.toFile(localPath); + if (file == null) { + Log_OC.w(TAG, "removeLocalCopyIfNeeded: file exists -> skip"); + return true; + } + + if (ocFile.isFolder()) { + Log_OC.w(TAG, "removeLocalCopyIfNeeded: file is folder -> skip"); + return true; + } + + String expectedPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), ocFile); + if (!localPath.equalsIgnoreCase(expectedPath)) { + Log_OC.w(TAG, "removeLocalCopyIfNeeded: Path mismatch! Expected " + expectedPath + + " but found " + localPath + ". Skipping deletion to prevent data loss."); + } + + Log_OC.d(TAG, "removeLocalCopyIfNeeded: deleting local file -> " + localPath); + boolean success = false; + try { + success = new File(expectedPath).delete(); + } catch (Exception e) { + Log_OC.e(TAG, "removeLocalCopyIfNeeded: deletion error: ", e); + } + Log_OC.d(TAG, "removeLocalCopyIfNeeded: file deletion result=" + success); + + if (!success) { + return false; + } + + deleteFileInMediaScan(localPath); + + if (!removeDBData) { + Log_OC.d(TAG, "removeLocalCopyIfNeeded: updating DB after local deletion"); + ocFile.setStoragePath(null); + saveFile(ocFile); + saveConflict(ocFile, null); + } + + return true; + } + + public boolean removeFolder(OCFile folder, boolean removeDBData, boolean removeLocalContent) { + if (folder == null) { + Log_OC.d(TAG,"removeFolder: folder is null"); + return false; + } + + if (!folder.isFolder()) { + Log_OC.d(TAG,"removeFolder: not a folder -> " + folder.getRemotePath()); + return false; + } + + Log_OC.d(TAG,"removeFolder: start -> " + folder.getRemotePath() + + " | removeDBData=" + removeDBData + + " | removeLocalContent=" + removeLocalContent); + + boolean success = true; + + if (removeDBData && folder.getFileId() != -1) { + Log_OC.d(TAG,"removeFolder: removing from DB -> fileId=" + folder.getFileId()); + success = removeFolderInDb(folder); + Log_OC.d(TAG,"removeFolder: DB removal result=" + success); + } + + if (success && removeLocalContent) { + Log_OC.d(TAG,"removeFolder: removing local content -> " + folder.getStoragePath()); + success = removeLocalFolder(folder); + Log_OC.d(TAG,"removeFolder: local removal result=" + success); + } + + Log_OC.d(TAG, "removeFolder: finished -> result=" + success); + return success; + } + + private boolean removeFolderInDb(OCFile folder) { + return fileDao.deleteFolderWithDescendants(user.getAccountName(), folder.getFileId()) > 0; + } + + private boolean removeLocalFolder(OCFile folder) { + if (folder == null) { + Log_OC.d(TAG, "removeLocalFolder: folder is null"); + return false; + } + + String localFolderPath = FileStorageUtils + .getDefaultSavePathFor(user.getAccountName(), folder); + File localFolder = new File(localFolderPath); + + if (!localFolder.exists()) { + Log_OC.d(TAG, "removeLocalFolder: local folder does not exist -> " + localFolderPath); + return true; + } + + Log_OC.d(TAG, "removeLocalFolder: start -> " + localFolderPath); + + boolean success = true; + + // remove DB content + List files = getFolderContent(folder.getFileId(), false); + Log_OC.d(TAG, "removeLocalFolder: found " + files.size() + " entries in DB"); + + for (OCFile ocFile : files) { + if (!success) { + break; + } + + if (ocFile.isFolder()) { + Log_OC.d(TAG, "removeLocalFolder: removing subfolder -> " + ocFile.getRemotePath()); + success = removeLocalFolder(ocFile); + Log_OC.d(TAG, "removeLocalFolder: subfolder removal result=" + success); + + } else if (ocFile.isDown()) { + + File localFile = new File(ocFile.getStoragePath()); + Log_OC.d(TAG, "removeLocalFolder: deleting file -> " + ocFile.getStoragePath()); + + boolean deleted = localFile.delete(); + success = deleted; + + Log_OC.d(TAG, "removeLocalFolder: file deletion result=" + deleted); + + if (deleted) { + deleteFileInMediaScan(ocFile.getStoragePath()); + ocFile.setStoragePath(null); + saveFile(ocFile); + } + } + } + + // remove folder itself (and any untracked content) + if (success) { + Log_OC.d(TAG, "removeLocalFolder: deleting folder -> " + localFolder.getAbsolutePath()); + success = removeLocalFolder(localFolder); + Log_OC.d(TAG, "removeLocalFolder: folder deletion result=" + success); + } + + Log_OC.d(TAG, "removeLocalFolder: finished -> result=" + success); + return success; + } + + private boolean removeLocalFolder(File localFolder) { + if (localFolder == null) { + Log_OC.d(TAG, "removeLocalFolder(File): folder is null"); + return false; + } + + if (!localFolder.exists()) { + Log_OC.d(TAG, "removeLocalFolder(File): folder does not exist -> " + localFolder.getAbsolutePath()); + return true; + } + + Log_OC.d(TAG, "removeLocalFolder(File): start -> " + localFolder.getAbsolutePath()); + + File[] children = localFolder.listFiles(); + if (children != null) { + for (File child : children) { + boolean childDeleted; + + if (child.isDirectory()) { + childDeleted = removeLocalFolder(child); + } else { + childDeleted = child.delete(); + Log_OC.d(TAG, "removeLocalFolder(File): deleting file -> " + child.getAbsolutePath() + " result=" + childDeleted); + } + + if (!childDeleted) { + Log_OC.d(TAG, "removeLocalFolder(File): failed at -> " + child.getAbsolutePath()); + return false; + } + } + } + + boolean folderDeleted = localFolder.delete(); + Log_OC.d(TAG, "removeLocalFolder(File): deleting folder -> " + localFolder.getAbsolutePath() + " result=" + folderDeleted); + + return folderDeleted; + } + // endregion + + /** + * Updates database and file system for a file or folder that was moved to a different location. + *

+ * TODO explore better (faster) implementations TODO throw exceptions up ! + */ + public void moveLocalFile(OCFile ocFile, String targetPath, String targetParentPath) { + if (ocFile.fileExists() && !OCFile.ROOT_PATH.equals(ocFile.getFileName())) { + + OCFile targetParent = getFileByPath(targetParentPath); + if (targetParent == null) { + throw new IllegalStateException("Parent folder of the target path does not exist!!"); + } + + String oldPath = ocFile.getRemotePath(); + + /// 1. get all the descendants of the moved element in a single QUERY + List fileEntities = + fileDao.getFolderWithDescendants(oldPath + "%", user.getAccountName()); + + /// 2. prepare a batch of update operations to change all the descendants + ArrayList operations = new ArrayList<>(fileEntities.size()); + String defaultSavePath = FileStorageUtils.getSavePath(user.getAccountName()); + List originalPathsToTriggerMediaScan = new ArrayList<>(); + List newPathsToTriggerMediaScan = new ArrayList<>(); + + int lengthOfOldPath = oldPath.length(); + int lengthOfOldStoragePath = defaultSavePath.length() + lengthOfOldPath; + for (FileEntity fileEntity : fileEntities) { + ContentValues contentValues = new ContentValues(); // keep construction in the loop + OCFile childFile = createFileInstance(fileEntity); + contentValues.put( + ProviderTableMeta.FILE_PATH, + targetPath + childFile.getRemotePath().substring(lengthOfOldPath) + ); + + if (!childFile.isEncrypted()) { + contentValues.put( + ProviderTableMeta.FILE_PATH_DECRYPTED, + targetPath + childFile.getRemotePath().substring(lengthOfOldPath) + ); + } + + if (childFile.getStoragePath() != null && childFile.getStoragePath().startsWith(defaultSavePath)) { + // update link to downloaded content - but local move is not done here! + String targetLocalPath = defaultSavePath + targetPath + + childFile.getStoragePath().substring(lengthOfOldStoragePath); + + contentValues.put(ProviderTableMeta.FILE_STORAGE_PATH, targetLocalPath); + + if (MimeTypeUtil.isMedia(childFile.getMimeType())) { + originalPathsToTriggerMediaScan.add(childFile.getStoragePath()); + newPathsToTriggerMediaScan.add(targetLocalPath); + } + + } + + if (childFile.getRemotePath().equals(ocFile.getRemotePath())) { + contentValues.put(ProviderTableMeta.FILE_PARENT, targetParent.getFileId()); + } + + operations.add( + ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI) + .withValues(contentValues) + .withSelection(ProviderTableMeta._ID + " = ?", new String[]{String.valueOf(childFile.getFileId())}) + .build()); + + } + + /// 3. apply updates in batch + try { + if (getContentResolver() != null) { + getContentResolver().applyBatch(MainApp.getAuthority(), operations); + } else { + getContentProviderClient().applyBatch(operations); + } + + } catch (Exception e) { + Log_OC.e(TAG, "Fail to update " + ocFile.getFileId() + " and descendants in database", e); + } + + /// 4. move in local file system + String originalLocalPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), ocFile); + String targetLocalPath = defaultSavePath + targetPath; + File localFile = new File(originalLocalPath); + boolean renamed = false; + + if (localFile.exists()) { + File targetFile = new File(targetLocalPath); + File targetFolder = targetFile.getParentFile(); + if (targetFolder != null && !targetFolder.exists() && !targetFolder.mkdirs()) { + Log_OC.e(TAG, "Unable to create parent folder " + targetFolder.getAbsolutePath()); + } + renamed = localFile.renameTo(targetFile); + } + + if (renamed) { + Iterator pathIterator = originalPathsToTriggerMediaScan.iterator(); + while (pathIterator.hasNext()) { + // Notify MediaScanner about removed file + deleteFileInMediaScan(pathIterator.next()); + } + + pathIterator = newPathsToTriggerMediaScan.iterator(); + while (pathIterator.hasNext()) { + // Notify MediaScanner about new file/folder + triggerMediaScan(pathIterator.next()); + } + } + } + } + + public void copyLocalFile(OCFile ocFile, String targetPath) { + if (ocFile.fileExists() && !OCFile.ROOT_PATH.equals(ocFile.getFileName())) { + String localPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), ocFile); + File localFile = new File(localPath); + boolean copied = false; + String defaultSavePath = FileStorageUtils.getSavePath(user.getAccountName()); + if (localFile.exists()) { + File targetFile = new File(defaultSavePath + targetPath); + File targetFolder = targetFile.getParentFile(); + if (targetFolder != null && !targetFolder.exists() && !targetFolder.mkdirs()) { + Log_OC.e(TAG, "Unable to create parent folder " + targetFolder.getAbsolutePath()); + } + copied = FileStorageUtils.copyFile(localFile, targetFile); + } + Log_OC.d(TAG, "Local file COPIED : " + copied); + } + } + + /** + * This method does not require {@link FileDataStorageManager} being initialized with any specific user. Migration + * can be performed with {@link com.nextcloud.client.account.AnonymousUser}. + */ + public void migrateStoredFiles(String sourcePath, String destinationPath) + throws RemoteException, OperationApplicationException { + Cursor cursor; + try { + if (getContentResolver() != null) { + cursor = getContentResolver().query(ProviderTableMeta.CONTENT_URI_FILE, + null, + ProviderTableMeta.FILE_STORAGE_PATH + " IS NOT NULL", + null, + null); + + } else { + cursor = getContentProviderClient().query(ProviderTableMeta.CONTENT_URI_FILE, + new String[]{ProviderTableMeta._ID, ProviderTableMeta.FILE_STORAGE_PATH}, + ProviderTableMeta.FILE_STORAGE_PATH + " IS NOT NULL", + null, + null); + } + } catch (RemoteException e) { + Log_OC.e(TAG, e.getMessage(), e); + throw e; + } + + ArrayList operations = new ArrayList<>(cursor.getCount()); + if (cursor.moveToFirst()) { + String[] fileId = new String[1]; + do { + ContentValues cv = new ContentValues(); + fileId[0] = String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ProviderTableMeta._ID))); + String oldFileStoragePath = + cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_STORAGE_PATH)); + + if (oldFileStoragePath.startsWith(sourcePath)) { + + cv.put(ProviderTableMeta.FILE_STORAGE_PATH, + oldFileStoragePath.replaceFirst(sourcePath, destinationPath)); + + operations.add( + ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI). + withValues(cv). + withSelection(ProviderTableMeta._ID + "=?", fileId) + .build()); + } + + } while (cursor.moveToNext()); + } + cursor.close(); + + /// 3. apply updates in batch + if (getContentResolver() != null) { + getContentResolver().applyBatch(MainApp.getAuthority(), operations); + } else { + getContentProviderClient().applyBatch(operations); + } + } + + private List getFolderContent(long parentId, boolean onlyOnDevice) { + Log_OC.d(TAG, "getFolderContent - start"); + List folderContent = new ArrayList<>(); + + List files = fileDao.getFolderContent(parentId); + for (FileEntity fileEntity : files) { + OCFile child = createFileInstance(fileEntity); + if (!onlyOnDevice || child.existsOnDevice()) { + folderContent.add(child); + } + } + + Log_OC.d(TAG, "getFolderContent - finished"); + return folderContent; + } + + private OCFile createRootDir() { + OCFile ocFile = new OCFile(OCFile.ROOT_PATH); + ocFile.setMimeType(MimeType.DIRECTORY); + ocFile.setParentId(FileDataStorageManager.ROOT_PARENT_ID); + saveFile(ocFile); + + return ocFile; + } + + @Nullable + private OCFile createFileInstanceFromVirtual(Cursor cursor) { + long fileId = cursor.getLong(cursor.getColumnIndexOrThrow(ProviderTableMeta.VIRTUAL_OCFILE_ID)); + + return getFileById(fileId); + } + + private int nullToZero(Integer i) { + return (i == null) ? 0 : i; + } + + private long nullToZero(Long i) { + return (i == null) ? 0 : i; + } + + private long nullToMinusOne(Long i) { + return (i == null) ? -1L : i; + } + + public OCFile createFileInstance(FileEntity fileEntity) { + OCFile ocFile = new OCFile(fileEntity.getPath()); + ocFile.setDecryptedRemotePath(fileEntity.getPathDecrypted()); + ocFile.setFileId(nullToZero(fileEntity.getId())); + ocFile.setParentId(nullToZero(fileEntity.getParent())); + ocFile.setMimeType(fileEntity.getContentType()); + ocFile.setStoragePath(fileEntity.getStoragePath()); + if (ocFile.getStoragePath() == null && ocFile.isFolder()) { + // Apparently storagePath is filled only for regular files - even in the current (Jan 2022) implementation. + // Check below is still required for directories. + // + // try to find existing file and bind it with current account; + // with the current update of SynchronizeFolderOperation, this won't be + // necessary anymore after a full synchronization of the account + File file = new File(FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), ocFile)); + if (file.exists()) { + ocFile.setStoragePath(file.getAbsolutePath()); + ocFile.setLastSyncDateForData(file.lastModified()); + } + } + ocFile.setFileLength(nullToZero(fileEntity.getContentLength())); + ocFile.setUploadTimestamp(nullToZero(fileEntity.getUploaded())); + ocFile.setCreationTimestamp(nullToZero(fileEntity.getCreation())); + ocFile.setModificationTimestamp(nullToZero(fileEntity.getModified())); + ocFile.setModificationTimestampAtLastSyncForData(nullToZero(fileEntity.getModifiedAtLastSyncForData())); + ocFile.setLastSyncDateForProperties(nullToZero(fileEntity.getLastSyncDate())); + ocFile.setLastSyncDateForData(nullToZero(fileEntity.getLastSyncDateForData())); + ocFile.setEtag(fileEntity.getEtag()); + ocFile.setEtagOnServer(fileEntity.getEtagOnServer()); + ocFile.setSharedViaLink(nullToZero(fileEntity.getSharedViaLink()) == 1); + ocFile.setSharedWithSharee(nullToZero(fileEntity.getSharedWithSharee()) == 1); + ocFile.setPermissions(fileEntity.getPermissions()); + ocFile.setRemoteId(fileEntity.getRemoteId()); + ocFile.setLocalId(fileEntity.getLocalId()); + ocFile.setUpdateThumbnailNeeded(nullToZero(fileEntity.getUpdateThumbnail()) == 1); + ocFile.setDownloading(nullToZero(fileEntity.isDownloading()) == 1); + ocFile.setEtagInConflict(fileEntity.getEtagInConflict()); + ocFile.setFavorite(nullToZero(fileEntity.getFavorite()) == 1); + ocFile.setEncrypted(nullToZero(fileEntity.isEncrypted()) == 1); +// if (ocFile.isEncrypted()) { +// ocFile.setFileName(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_NAME))); +// } + Integer mountType = fileEntity.getMountType(); // TODO - any default when NULL returned? + if (mountType != null) { + ocFile.setMountType(WebdavEntry.MountType.values()[mountType]); + } + ocFile.setPreviewAvailable(nullToZero(fileEntity.getHasPreview()) == 1); + ocFile.setUnreadCommentsCount(nullToZero(fileEntity.getUnreadCommentsCount())); + ocFile.setOwnerId(fileEntity.getOwnerId()); + ocFile.setOwnerDisplayName(fileEntity.getOwnerDisplayName()); + ocFile.setNote(fileEntity.getNote()); + ocFile.setRichWorkspace(fileEntity.getRichWorkspace()); + ocFile.setLocked(nullToZero(fileEntity.getLocked()) == 1); + + final int lockTypeInt = nullToZero(fileEntity.getLockType()); // TODO - what value should be used for NULL??? + ocFile.setLockType(lockTypeInt != -1 ? FileLockType.fromValue(lockTypeInt) : null); + ocFile.setLockOwnerId(fileEntity.getLockOwner()); + ocFile.setLockOwnerDisplayName(fileEntity.getLockOwnerDisplayName()); + ocFile.setLockOwnerEditor(fileEntity.getLockOwnerEditor()); + ocFile.setLockTimestamp(nullToZero(fileEntity.getLockTimestamp())); + ocFile.setLockTimeout(nullToZero(fileEntity.getLockTimeout())); + ocFile.setLockToken(fileEntity.getLockToken()); + ocFile.setLivePhoto(fileEntity.getMetadataLivePhoto()); + ocFile.setHidden(nullToZero(fileEntity.getHidden()) == 1); + ocFile.setE2eCounter(fileEntity.getE2eCounter()); + ocFile.setInternalFolderSyncTimestamp(nullToMinusOne(fileEntity.getInternalTwoWaySync())); + + String sharees = fileEntity.getSharees(); + // Surprisingly JSON deserialization causes significant overhead. + // Avoid it in common, trivial cases (null/empty). + if (sharees == null || sharees.isEmpty() || + JSON_NULL_STRING.equals(sharees) || JSON_EMPTY_ARRAY.equals(sharees)) { + ocFile.setSharees(new ArrayList<>()); + } else { + try { + ShareeUser[] shareesArray = gson.fromJson(sharees, ShareeUser[].class); + ocFile.setSharees(new ArrayList<>(Arrays.asList(shareesArray))); + } catch (JsonSyntaxException e) { + // ignore saved value due to api change + ocFile.setSharees(new ArrayList<>()); + } + } + + String tags = fileEntity.getTags(); + if (tags == null || tags.isEmpty() || + JSON_NULL_STRING.equals(tags) || JSON_EMPTY_ARRAY.equals(tags)) { + ocFile.setTags(new ArrayList<>()); + } else { + try { + Tag[] tagsArray = gson.fromJson(tags, Tag[].class); + ocFile.setTags(new ArrayList<>(Arrays.asList(tagsArray))); + } catch (JsonSyntaxException e) { + // ignore saved value due to api change + ocFile.setTags(new ArrayList<>()); + } + } + + String metadataSize = fileEntity.getMetadataSize(); + // Surprisingly JSON deserialization causes significant overhead. + // Avoid it in common, trivial cases (null/empty). + if (metadataSize != null && !metadataSize.isEmpty() && !JSON_NULL_STRING.equalsIgnoreCase(metadataSize)) { + ImageDimension imageDimension = gson.fromJson(metadataSize, ImageDimension.class); + if (imageDimension != null) { + ocFile.setImageDimension(imageDimension); + } + } + + String metadataGPS = fileEntity.getMetadataGPS(); + // Surprisingly JSON deserialization causes significant overhead. + // Avoid it in common, trivial cases (null/empty). + if (!(metadataGPS == null || metadataGPS.isEmpty() || JSON_NULL_STRING.equals(metadataGPS))) { + GeoLocation geoLocation = gson.fromJson(metadataGPS, GeoLocation.class); + if (geoLocation != null) { + ocFile.setGeoLocation(geoLocation); + } + } + + return ocFile; + } + + public boolean saveShare(OCShare share) { + boolean overridden = false; + + ContentValues contentValues = createContentValueForShare(share); + + if (shareExistsForRemoteId(share.getRemoteId())) {// for renamed files; no more delete and create + overridden = true; + if (getContentResolver() != null) { + getContentResolver().update(ProviderTableMeta.CONTENT_URI_SHARE, + contentValues, + ProviderTableMeta.OCSHARES_ID_REMOTE_SHARED + "=?", + new String[]{String.valueOf(share.getRemoteId())}); + } else { + try { + getContentProviderClient().update(ProviderTableMeta.CONTENT_URI_SHARE, + contentValues, + ProviderTableMeta.OCSHARES_ID_REMOTE_SHARED + "=?", + new String[]{String.valueOf(share.getRemoteId())}); + } catch (RemoteException e) { + Log_OC.e(TAG, FAILED_TO_INSERT_MSG + e.getMessage(), e); + } + } + } else { + Uri result_uri = null; + if (getContentResolver() != null) { + result_uri = getContentResolver().insert(ProviderTableMeta.CONTENT_URI_SHARE, contentValues); + } else { + try { + result_uri = getContentProviderClient().insert(ProviderTableMeta.CONTENT_URI_SHARE, contentValues); + } catch (RemoteException e) { + Log_OC.e(TAG, FAILED_TO_INSERT_MSG + e.getMessage(), e); + } + } + if (result_uri != null) { + long new_id = Long.parseLong(result_uri.getPathSegments().get(1)); + share.setId(new_id); + } + } + + return overridden; + } + + /** + * Retrieves an stored {@link OCShare} given its id. + * + * @param id Identifier. + * @return Stored {@link OCShare} given its id. + */ + public OCShare getShareById(long id) { + OCShare share = null; + Cursor cursor = getShareCursorForValue(ProviderTableMeta._ID, String.valueOf(id)); + if (cursor != null) { + if (cursor.moveToFirst()) { + share = createShareInstance(cursor); + } + cursor.close(); + } + return share; + } + + + /** + * Checks the existence of an stored {@link OCShare} matching the given remote id (not to be confused with the local + * id) in the current account. + * + * @param remoteId Remote of the share in the server. + * @return 'True' if a matching {@link OCShare} is stored in the current account. + */ + private boolean shareExistsForRemoteId(long remoteId) { + return shareExistsForValue(ProviderTableMeta.OCSHARES_ID_REMOTE_SHARED, String.valueOf(remoteId)); + } + + + /** + * Checks the existance of an stored {@link OCShare} in the current account matching a given column and a value for + * that column + * + * @param key Name of the column to match. + * @param value Value of the column to match. + * @return 'True' if a matching {@link OCShare} is stored in the current account. + */ + private boolean shareExistsForValue(String key, String value) { + Cursor cursor = getShareCursorForValue(key, value); + boolean retval = cursor.moveToFirst(); + cursor.close(); + + return retval; + } + + + /** + * Gets a {@link Cursor} for an stored {@link OCShare} in the current account matching a given column and a value + * for that column + * + * @param key Name of the column to match. + * @param value Value of the column to match. + * @return 'True' if a matching {@link OCShare} is stored in the current account. + */ + private Cursor getShareCursorForValue(String key, String value) { + Cursor cursor; + if (getContentResolver() != null) { + cursor = getContentResolver() + .query(ProviderTableMeta.CONTENT_URI_SHARE, + null, + key + AND + + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + "=?", + new String[]{value, user.getAccountName()}, + null + ); + } else { + try { + cursor = getContentProviderClient().query( + ProviderTableMeta.CONTENT_URI_SHARE, + null, + key + AND + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + "=?", + new String[]{value, user.getAccountName()}, + null + ); + } catch (RemoteException e) { + Log_OC.w(TAG, "Could not get details, assuming share does not exist: " + e.getMessage()); + cursor = null; + } + } + return cursor; + } + + + /** + * Get first share bound to a file with a known path and given {@link ShareType}. + * + * @param path Path of the file. + * @param type Type of the share to get + * @param shareWith Target of the share. Ignored in type is {@link ShareType#PUBLIC_LINK} + * @return All {@link OCShare} instance found in DB bound to the file in 'path' + */ + public List getSharesByPathAndType(String path, ShareType type, String shareWith) { + Cursor cursor; + + String selection = ProviderTableMeta.OCSHARES_PATH + AND + + ProviderTableMeta.OCSHARES_SHARE_TYPE + AND + + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + " = ?"; + + if (ShareType.PUBLIC_LINK != type) { + selection += " AND " + ProviderTableMeta.OCSHARES_SHARE_WITH + " = ?"; + } + + String[] selectionArgs; + if (ShareType.PUBLIC_LINK == type) { + selectionArgs = new String[]{ + path, + Integer.toString(type.getValue()), + user.getAccountName() + }; + } else { + if (shareWith == null) { + selectionArgs = new String[]{ + path, + Integer.toString(type.getValue()), + user.getAccountName(), + "" + }; + } else { + selectionArgs = new String[]{ + path, + Integer.toString(type.getValue()), + user.getAccountName(), + shareWith + }; + } + } + + if (getContentResolver() != null) { + cursor = getContentResolver().query( + ProviderTableMeta.CONTENT_URI_SHARE, + null, + selection, selectionArgs, + null); + } else { + try { + cursor = getContentProviderClient().query( + ProviderTableMeta.CONTENT_URI_SHARE, + null, + selection, selectionArgs, + null); + + } catch (RemoteException e) { + Log_OC.e(TAG, "Could not get file details: " + e.getMessage(), e); + cursor = null; + } + } + + List shares = new ArrayList<>(); + OCShare share; + if (cursor != null) { + if (cursor.moveToFirst()) { + do { + share = createShareInstance(cursor); + shares.add(share); + } while (cursor.moveToNext()); + } + cursor.close(); + } + return shares; + } + + private ContentValues createContentValueForShare(OCShare share) { + ContentValues contentValues = new ContentValues(); + contentValues.put(ProviderTableMeta.OCSHARES_FILE_SOURCE, share.getFileSource()); + contentValues.put(ProviderTableMeta.OCSHARES_ITEM_SOURCE, share.getItemSource()); + + ShareType shareType = share.getShareType(); + if (shareType != null) { + contentValues.put(ProviderTableMeta.OCSHARES_SHARE_TYPE, shareType.getValue()); + } + + contentValues.put(ProviderTableMeta.OCSHARES_SHARE_WITH, share.getShareWith()); + contentValues.put(ProviderTableMeta.OCSHARES_PATH, share.getPath()); + contentValues.put(ProviderTableMeta.OCSHARES_PERMISSIONS, share.getPermissions()); + contentValues.put(ProviderTableMeta.OCSHARES_SHARED_DATE, share.getSharedDate()); + contentValues.put(ProviderTableMeta.OCSHARES_EXPIRATION_DATE, share.getExpirationDate()); + contentValues.put(ProviderTableMeta.OCSHARES_TOKEN, share.getToken()); + contentValues.put(ProviderTableMeta.OCSHARES_SHARE_WITH_DISPLAY_NAME, share.getSharedWithDisplayName()); + contentValues.put(ProviderTableMeta.OCSHARES_IS_DIRECTORY, share.isFolder() ? 1 : 0); + contentValues.put(ProviderTableMeta.OCSHARES_USER_ID, share.getUserId()); + contentValues.put(ProviderTableMeta.OCSHARES_ID_REMOTE_SHARED, share.getRemoteId()); + contentValues.put(ProviderTableMeta.OCSHARES_ACCOUNT_OWNER, user.getAccountName()); + contentValues.put(ProviderTableMeta.OCSHARES_IS_PASSWORD_PROTECTED, share.isPasswordProtected() ? 1 : 0); + contentValues.put(ProviderTableMeta.OCSHARES_NOTE, share.getNote()); + contentValues.put(ProviderTableMeta.OCSHARES_HIDE_DOWNLOAD, share.isHideFileDownload()); + contentValues.put(ProviderTableMeta.OCSHARES_SHARE_LINK, share.getShareLink()); + contentValues.put(ProviderTableMeta.OCSHARES_SHARE_LABEL, share.getLabel()); + + FileDownloadLimit downloadLimit = share.getFileDownloadLimit(); + setDownloadLimitToContentValues(contentValues, downloadLimit); + + contentValues.put(ProviderTableMeta.OCSHARES_ATTRIBUTES, share.getAttributes()); + + return contentValues; + } + + // test with null cursor? + private OCShare createShareInstance(Cursor cursor) { + OCShare share = new OCShare(getString(cursor, ProviderTableMeta.OCSHARES_PATH)); + share.setId(getLong(cursor, ProviderTableMeta._ID)); + share.setFileSource(getLong(cursor, ProviderTableMeta.OCSHARES_ITEM_SOURCE)); + share.setShareType(ShareType.fromValue(getInt(cursor, ProviderTableMeta.OCSHARES_SHARE_TYPE))); + share.setShareWith(getString(cursor, ProviderTableMeta.OCSHARES_SHARE_WITH)); + share.setPermissions(getInt(cursor, ProviderTableMeta.OCSHARES_PERMISSIONS)); + share.setSharedDate(getLong(cursor, ProviderTableMeta.OCSHARES_SHARED_DATE)); + share.setExpirationDate(getLong(cursor, ProviderTableMeta.OCSHARES_EXPIRATION_DATE)); + String token = getString(cursor, ProviderTableMeta.OCSHARES_TOKEN); + share.setToken(token); + share.setSharedWithDisplayName(getString(cursor, ProviderTableMeta.OCSHARES_SHARE_WITH_DISPLAY_NAME)); + share.setFolder(getInt(cursor, ProviderTableMeta.OCSHARES_IS_DIRECTORY) == 1); + share.setUserId(getString(cursor, ProviderTableMeta.OCSHARES_USER_ID)); + share.setRemoteId(getLong(cursor, ProviderTableMeta.OCSHARES_ID_REMOTE_SHARED)); + share.setPasswordProtected(getInt(cursor, ProviderTableMeta.OCSHARES_IS_PASSWORD_PROTECTED) == 1); + share.setNote(getString(cursor, ProviderTableMeta.OCSHARES_NOTE)); + share.setHideFileDownload(getInt(cursor, ProviderTableMeta.OCSHARES_HIDE_DOWNLOAD) == 1); + share.setShareLink(getString(cursor, ProviderTableMeta.OCSHARES_SHARE_LINK)); + share.setLabel(getString(cursor, ProviderTableMeta.OCSHARES_SHARE_LABEL)); + + FileDownloadLimit fileDownloadLimit = getDownloadLimitFromCursor(cursor, token); + if (fileDownloadLimit != null) { + share.setFileDownloadLimit(fileDownloadLimit); + } + + share.setAttributes(getString(cursor, ProviderTableMeta.OCSHARES_ATTRIBUTES)); + + return share; + } + + private void setDownloadLimitToContentValues(ContentValues contentValues, FileDownloadLimit downloadLimit) { + if (downloadLimit != null) { + contentValues.put(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT, downloadLimit.getLimit()); + contentValues.put(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT, downloadLimit.getCount()); + return; + } + + contentValues.putNull(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT); + contentValues.putNull(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT); + } + + @Nullable + private FileDownloadLimit getDownloadLimitFromCursor(Cursor cursor, String token) { + if (token == null || cursor == null) { + return null; + } + + int limit = getIntOrDefault(cursor, ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT); + int count = getIntOrDefault(cursor, ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT); + if (limit != DEFAULT_CURSOR_INT_VALUE && count != DEFAULT_CURSOR_INT_VALUE) { + return new FileDownloadLimit(token, limit, count); + } + + return null; + } + + /** + * Retrieves an integer value from the specified column in the cursor. + *

+ * If the column does not exist (i.e., {@code cursor.getColumnIndex(columnName)} returns -1), + * this method returns {@code -1} as a default value. + *

+ * + * @param cursor The Cursor from which to retrieve the value. + * @param columnName The name of the column to retrieve the integer from. + * @return The integer value from the column, or {@code -1} if the column is not found. + */ + private int getIntOrDefault(Cursor cursor, String columnName) { + int index = cursor.getColumnIndex(columnName); + if (index == DEFAULT_CURSOR_INT_VALUE) { + return DEFAULT_CURSOR_INT_VALUE; + } + + return cursor.getInt(index); + } + + private void resetShareFlagInAFile(String filePath) { + ContentValues contentValues = new ContentValues(); + contentValues.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, Boolean.FALSE); + contentValues.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, Boolean.FALSE); + String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + ProviderTableMeta.FILE_PATH + " = ?"; + String[] whereArgs = new String[]{user.getAccountName(), filePath}; + + if (getContentResolver() != null) { + getContentResolver().update(ProviderTableMeta.CONTENT_URI, contentValues, where, whereArgs); + + } else { + try { + getContentProviderClient().update(ProviderTableMeta.CONTENT_URI, contentValues, where, whereArgs); + } catch (RemoteException e) { + Log_OC.e(TAG, "Exception in resetShareFlagsInFolder " + e.getMessage(), e); + } + } + } + + public void removeShare(OCShare share) { + Uri contentUriShare = ProviderTableMeta.CONTENT_URI_SHARE; + String where = ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + AND + + ProviderTableMeta._ID + " = ?"; + String[] whereArgs = {user.getAccountName(), Long.toString(share.getId())}; + + if (getContentProviderClient() != null) { + try { + getContentProviderClient().delete(contentUriShare, where, whereArgs); + } catch (RemoteException e) { + Log_OC.d(TAG, e.getMessage(), e); + } + } else { + getContentResolver().delete(contentUriShare, where, whereArgs); + } + } + + public void saveSharesFromRemoteFile(List shares) { + if (shares == null || shares.isEmpty()) { + return; + } + + // Prepare reset operations + Set uniquePaths = new HashSet<>(); + for (RemoteFile share : shares) { + uniquePaths.add(share.getRemotePath()); + } + + ArrayList resetOperations = new ArrayList<>(); + for (String path : uniquePaths) { + resetShareFlagInAFile(path); + var removeOps = prepareRemoveSharesInFile(path, new ArrayList<>()); + if (!removeOps.isEmpty()) { + resetOperations.addAll(removeOps); + } + } + if (!resetOperations.isEmpty()) { + applyBatch(resetOperations); + } + + // Prepare insert operations + ArrayList insertOperations = prepareInsertSharesFromRemoteFile(shares); + if (!insertOperations.isEmpty()) { + applyBatch(insertOperations); + } + } + + /** + * Prepares a list of ContentProviderOperation insert operations based on share information + * found in the given iterable of RemoteFile objects. + *

+ * Each RemoteFile may have multiple share entries (sharees), and for each one, + * a corresponding ContentProviderOperation is created for insertion into the shares table. + * + * @param remoteFiles An iterable list of RemoteFile objects containing sharee data. + * @return A list of ContentProviderOperation objects for batch insertion into the content provider. + */ + private ArrayList prepareInsertSharesFromRemoteFile(Iterable remoteFiles) { + final ArrayList contentValueList = new ArrayList<>(); + for (RemoteFile remoteFile : remoteFiles) { + final var contentValues = ShareeEntry.Companion.getContentValues(remoteFile, user.getAccountName()); + if (contentValues == null) { + continue; + } + contentValueList.addAll(contentValues); + } + + ArrayList operations = new ArrayList<>(); + for (ContentValues contentValues : contentValueList) { + operations.add(ContentProviderOperation + .newInsert(ProviderTableMeta.CONTENT_URI_SHARE) + .withValues(contentValues) + .build()); + } + + return operations; + } + + public void saveSharesDB(List shares) { + ArrayList operations = new ArrayList<>(); + + // Reset flags & Remove shares for this files + String filePath = ""; + for (OCShare share : shares) { + if (!filePath.equals(share.getPath())) { + filePath = share.getPath(); + resetShareFlagInAFile(filePath); + operations = prepareRemoveSharesInFile(filePath, operations); + } + } + + // Add operations to insert shares + operations = prepareInsertShares(shares, operations); + + if (operations.isEmpty()) { + return; + } + + // apply operations in batch + Log_OC.d(TAG, String.format(Locale.ENGLISH, SENDING_TO_FILECONTENTPROVIDER_MSG, operations.size())); + applyBatch(operations); + } + + private void applyBatch(ArrayList operations) { + try { + if (getContentResolver() != null) { + getContentResolver().applyBatch(MainApp.getAuthority(), operations); + + } else { + getContentProviderClient().applyBatch(operations); + } + + } catch (OperationApplicationException | RemoteException e) { + Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e); + } + } + + public void removeSharesForFile(String remotePath) { + resetShareFlagInAFile(remotePath); + ArrayList operations = prepareRemoveSharesInFile(remotePath, new ArrayList<>()); + // apply operations in batch + if (operations.size() > 0) { + Log_OC.d(TAG, String.format(Locale.ENGLISH, SENDING_TO_FILECONTENTPROVIDER_MSG, operations.size())); + try { + if (getContentResolver() != null) { + getContentResolver().applyBatch(MainApp.getAuthority(), operations); + + } else { + getContentProviderClient().applyBatch(operations); + } + + } catch (OperationApplicationException | RemoteException e) { + Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e); + } + } + } + + /** + * Prepare operations to insert or update files to save in the given folder + * + * @param shares List of shares to insert + * @param operations List of operations + * @return + */ + private ArrayList prepareInsertShares(Iterable shares, ArrayList operations) { + + ContentValues contentValues; + // prepare operations to insert or update files to save in the given folder + for (OCShare share : shares) { + contentValues = createContentValueForShare(share); + + // adding a new share resource + operations.add(ContentProviderOperation + .newInsert(ProviderTableMeta.CONTENT_URI_SHARE) + .withValues(contentValues) + .build()); + } + + return operations; + } + + private ArrayList prepareRemoveSharesInFile( + String filePath, ArrayList preparedOperations) { + + String where = ProviderTableMeta.OCSHARES_PATH + AND + + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + " = ?"; + String[] whereArgs = new String[]{filePath, user.getAccountName()}; + + preparedOperations.add( + ContentProviderOperation + .newDelete(ProviderTableMeta.CONTENT_URI_SHARE) + .withSelection(where, whereArgs) + .build() + ); + + return preparedOperations; + + } + + public List getSharesWithForAFile(String filePath, String accountName) { + String selection = ProviderTableMeta.OCSHARES_PATH + AND + + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + AND + + " (" + ProviderTableMeta.OCSHARES_SHARE_TYPE + " = ? OR " + + ProviderTableMeta.OCSHARES_SHARE_TYPE + " = ? OR " + + ProviderTableMeta.OCSHARES_SHARE_TYPE + " = ? OR " + + ProviderTableMeta.OCSHARES_SHARE_TYPE + " = ? OR " + + ProviderTableMeta.OCSHARES_SHARE_TYPE + " = ? OR " + + ProviderTableMeta.OCSHARES_SHARE_TYPE + " = ? OR " + + ProviderTableMeta.OCSHARES_SHARE_TYPE + " = ? ) "; + String[] selectionArgs = new String[]{filePath, accountName, + Integer.toString(ShareType.USER.getValue()), + Integer.toString(ShareType.GROUP.getValue()), + Integer.toString(ShareType.EMAIL.getValue()), + Integer.toString(ShareType.FEDERATED.getValue()), + Integer.toString(ShareType.FEDERATED_GROUP.getValue()), + Integer.toString(ShareType.ROOM.getValue()), + Integer.toString(ShareType.CIRCLE.getValue()) + }; + + Cursor cursor; + if (getContentResolver() != null) { + cursor = getContentResolver().query(ProviderTableMeta.CONTENT_URI_SHARE, + null, + selection, + selectionArgs, + null); + } else { + try { + cursor = getContentProviderClient().query(ProviderTableMeta.CONTENT_URI_SHARE, + null, + selection, + selectionArgs, + null); + + } catch (RemoteException e) { + Log_OC.e(TAG, "Could not get list of shares with: " + e.getMessage(), e); + cursor = null; + } + } + ArrayList shares = new ArrayList<>(); + OCShare share; + if (cursor != null) { + if (cursor.moveToFirst()) { + do { + share = createShareInstance(cursor); + shares.add(share); + } while (cursor.moveToNext()); + } + + cursor.close(); + } + + return shares; + } + + public static void triggerMediaScan(String path) { + triggerMediaScan(MainApp.getAppContext(), path, null); + } + + public static void triggerMediaScan(String path, OCFile file) { + triggerMediaScan(MainApp.getAppContext(), path, file); + } + + public static void triggerMediaScan(Context context, String path, OCFile file) { + if (path != null && !TextUtils.isEmpty(path)) { + String mimeType = file != null ? file.getMimeType() : null; + MediaScannerConnection.scanFile( + context, + new String[]{path}, + mimeType != null ? new String[]{mimeType} : null, + (scannedPath, scannedUri) -> { + if (scannedUri != null) { + Log_OC.d(TAG, "Media scan completed for " + scannedPath); + } else { + Log_OC.w(TAG, "Media scan failed for " + scannedPath); + } + } + ); + } + } + + public void deleteFileInMediaScan(String path) { + String mimetypeString = FileStorageUtils.getMimeTypeFromName(path); + ContentResolver contentResolver = getContentResolver(); + + if (contentResolver != null) { + if (MimeTypeUtil.isImage(mimetypeString)) { + // Images + contentResolver.delete(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + MediaStore.Images.Media.DATA + "=?", new String[]{path}); + } else if (MimeTypeUtil.isAudio(mimetypeString)) { + // Audio + contentResolver.delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + MediaStore.Audio.Media.DATA + "=?", new String[]{path}); + } else if (MimeTypeUtil.isVideo(mimetypeString)) { + // Video + contentResolver.delete(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + MediaStore.Video.Media.DATA + "=?", new String[]{path}); + } + } else { + ContentProviderClient contentProviderClient = getContentProviderClient(); + try { + if (MimeTypeUtil.isImage(mimetypeString)) { + // Images + contentProviderClient.delete(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + MediaStore.Images.Media.DATA + "=?", new String[]{path}); + } else if (MimeTypeUtil.isAudio(mimetypeString)) { + // Audio + contentProviderClient.delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + MediaStore.Audio.Media.DATA + "=?", new String[]{path}); + } else if (MimeTypeUtil.isVideo(mimetypeString)) { + // Video + contentProviderClient.delete(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + MediaStore.Video.Media.DATA + "=?", new String[]{path}); + } + } catch (RemoteException e) { + Log_OC.e(TAG, "Exception deleting media file in MediaStore " + e.getMessage(), e); + } + } + } + + @SuppressFBWarnings("PSC") + public void saveConflict(OCFile ocFile, String etagInConflict) { + ContentValues cv = new ContentValues(); + if (!ocFile.isDown()) { + cv.put(ProviderTableMeta.FILE_ETAG_IN_CONFLICT, (String) null); + } else { + cv.put(ProviderTableMeta.FILE_ETAG_IN_CONFLICT, etagInConflict); + } + + int updated = 0; + if (getContentResolver() != null) { + updated = getContentResolver().update( + ProviderTableMeta.CONTENT_URI_FILE, + cv, + ProviderTableMeta._ID + "=?", + new String[]{String.valueOf(ocFile.getFileId())} + ); + } else { + try { + updated = getContentProviderClient().update( + ProviderTableMeta.CONTENT_URI_FILE, + cv, + ProviderTableMeta._ID + "=?", + new String[]{String.valueOf(ocFile.getFileId())} + ); + } catch (RemoteException e) { + Log_OC.e(TAG, "Failed saving conflict in database " + e.getMessage(), e); + } + } + + Log_OC.d(TAG, "Number of files updated with CONFLICT: " + updated); + + if (updated > 0) { + if (etagInConflict != null && ocFile.isDown()) { + /// set conflict in all ancestor folders + + long parentId = ocFile.getParentId(); + Set ancestorIds = new HashSet<>(); + while (parentId != FileDataStorageManager.ROOT_PARENT_ID) { + ancestorIds.add(Long.toString(parentId)); + parentId = getFileById(parentId).getParentId(); + } + + if (ancestorIds.size() > 0) { + //TODO bug if ancestorIds.size() > 1000 + final StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(ProviderTableMeta._ID).append(" IN ("); + for (int i = 0; i < ancestorIds.size() - 1; i++) { + stringBuilder.append("?, "); + } + stringBuilder.append("?)"); + + if (getContentResolver() != null) { + getContentResolver().update( + ProviderTableMeta.CONTENT_URI_FILE, + cv, + stringBuilder.toString(), + ancestorIds.toArray(new String[]{}) + ); + } else { + try { + getContentProviderClient().update( + ProviderTableMeta.CONTENT_URI_FILE, + cv, + stringBuilder.toString(), + ancestorIds.toArray(new String[]{}) + ); + } catch (RemoteException e) { + Log_OC.e(TAG, "Failed saving conflict in database " + e.getMessage(), e); + } + } + } // else file is ROOT folder, no parent to set in conflict + + } else { + /// update conflict in ancestor folders + // (not directly unset; maybe there are more conflicts below them) + String parentPath = ocFile.getRemotePath(); + if (parentPath.endsWith(OCFile.PATH_SEPARATOR)) { + parentPath = parentPath.substring(0, parentPath.length() - 1); + } + parentPath = parentPath.substring(0, parentPath.lastIndexOf(OCFile.PATH_SEPARATOR) + 1); + + Log_OC.d(TAG, "checking parents to remove conflict; STARTING with " + parentPath); + while (parentPath.length() > 0) { + String[] projection = {ProviderTableMeta._ID}; + String whereForDescencentsInConflict = + ProviderTableMeta.FILE_ETAG_IN_CONFLICT + " IS NOT NULL AND " + + ProviderTableMeta.FILE_CONTENT_TYPE + " != 'DIR' AND " + + ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + + ProviderTableMeta.FILE_PATH + " LIKE ?"; + Cursor descendentsInConflict = null; + if (getContentResolver() != null) { + descendentsInConflict = getContentResolver().query( + ProviderTableMeta.CONTENT_URI_FILE, + projection, + whereForDescencentsInConflict, + new String[]{user.getAccountName(), parentPath + '%'}, + null + ); + } else { + try { + descendentsInConflict = getContentProviderClient().query( + ProviderTableMeta.CONTENT_URI_FILE, + projection, + whereForDescencentsInConflict, + new String[]{user.getAccountName(), parentPath + "%"}, + null + ); + } catch (RemoteException e) { + Log_OC.e(TAG, "Failed querying for descendents in conflict " + e.getMessage(), e); + } + } + + if (descendentsInConflict == null || descendentsInConflict.getCount() == 0) { + Log_OC.d(TAG, "NO MORE conflicts in " + parentPath); + if (getContentResolver() != null) { + getContentResolver().update( + ProviderTableMeta.CONTENT_URI_FILE, + cv, + ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + + ProviderTableMeta.FILE_PATH + "=?", + new String[]{user.getAccountName(), parentPath} + ); + } else { + try { + getContentProviderClient().update( + ProviderTableMeta.CONTENT_URI_FILE, + cv, + ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + + ProviderTableMeta.FILE_PATH + "=?" + , new String[]{user.getAccountName(), parentPath} + ); + } catch (RemoteException e) { + Log_OC.e(TAG, "Failed saving conflict in database " + e.getMessage(), e); + } + } + + } else { + Log_OC.d(TAG, "STILL " + descendentsInConflict.getCount() + " in " + parentPath); + } + + if (descendentsInConflict != null) { + descendentsInConflict.close(); + } + + parentPath = parentPath.substring(0, parentPath.length() - 1); // trim last / + parentPath = parentPath.substring(0, parentPath.lastIndexOf(OCFile.PATH_SEPARATOR) + 1); + Log_OC.d(TAG, "checking parents to remove conflict; NEXT " + parentPath); + } + } + } + } + + public void saveCapabilities(OCCapability capability) { + // Prepare capabilities data + ContentValues contentValues = createContentValues(user.getAccountName(), capability); + + if (capabilityExists(user.getAccountName())) { + if (getContentResolver() != null) { + getContentResolver().update(ProviderTableMeta.CONTENT_URI_CAPABILITIES, contentValues, + ProviderTableMeta.CAPABILITIES_ACCOUNT_NAME + "=?", + new String[]{user.getAccountName()}); + } else { + try { + getContentProviderClient().update(ProviderTableMeta.CONTENT_URI_CAPABILITIES, + contentValues, ProviderTableMeta.CAPABILITIES_ACCOUNT_NAME + "=?", + new String[]{user.getAccountName()}); + } catch (RemoteException e) { + Log_OC.e(TAG, FAILED_TO_INSERT_MSG + e.getMessage(), e); + } + } + } else { + Uri result_uri = null; + if (getContentResolver() != null) { + result_uri = getContentResolver().insert( + ProviderTableMeta.CONTENT_URI_CAPABILITIES, contentValues); + } else { + try { + result_uri = getContentProviderClient().insert( + ProviderTableMeta.CONTENT_URI_CAPABILITIES, contentValues); + } catch (RemoteException e) { + Log_OC.e(TAG, FAILED_TO_INSERT_MSG + e.getMessage(), e); + } + } + if (result_uri != null) { + long new_id = Long.parseLong(result_uri.getPathSegments() + .get(1)); + capability.setId(new_id); + capability.setAccountName(user.getAccountName()); + } + } + } + + @NonNull + private ContentValues createContentValues(String accountName, OCCapability capability) { + ContentValues contentValues = new ContentValues(); + contentValues.put(ProviderTableMeta.CAPABILITIES_ACCOUNT_NAME, + accountName); + contentValues.put(ProviderTableMeta.CAPABILITIES_VERSION_MAYOR, + capability.getVersionMayor()); + contentValues.put(ProviderTableMeta.CAPABILITIES_VERSION_MINOR, + capability.getVersionMinor()); + contentValues.put(ProviderTableMeta.CAPABILITIES_VERSION_MICRO, + capability.getVersionMicro()); + contentValues.put(ProviderTableMeta.CAPABILITIES_VERSION_STRING, + capability.getVersionString()); + contentValues.put(ProviderTableMeta.CAPABILITIES_VERSION_EDITION, + capability.getVersionEdition()); + contentValues.put(ProviderTableMeta.CAPABILITIES_EXTENDED_SUPPORT, + capability.getExtendedSupport().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_CORE_POLLINTERVAL, + capability.getCorePollInterval()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_API_ENABLED, + capability.getFilesSharingApiEnabled().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_ENABLED, + capability.getFilesSharingPublicEnabled().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED, + capability.getFilesSharingPublicPasswordEnforced().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_ASK_FOR_OPTIONAL_PASSWORD, + capability.getFilesSharingPublicAskForOptionalPassword().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENABLED, + capability.getFilesSharingPublicExpireDateEnabled().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_DAYS, + capability.getFilesSharingPublicExpireDateDays()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENFORCED, + capability.getFilesSharingPublicExpireDateEnforced().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_SEND_MAIL, + capability.getFilesSharingPublicSendMail().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_UPLOAD, + capability.getFilesSharingPublicUpload().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_USER_SEND_MAIL, + capability.getFilesSharingUserSendMail().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_RESHARING, + capability.getFilesSharingResharing().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_FEDERATION_OUTGOING, + capability.getFilesSharingFederationOutgoing().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SHARING_FEDERATION_INCOMING, + capability.getFilesSharingFederationIncoming().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_BIGFILECHUNKING, + capability.getFilesBigFileChunking().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_UNDELETE, + capability.getFilesUndelete().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_VERSIONING, + capability.getFilesVersioning().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_EXTERNAL_LINKS, + capability.getExternalLinks().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_NAME, + capability.getServerName()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_COLOR, + capability.getServerColor()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_TEXT_COLOR, + capability.getServerTextColor()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_ELEMENT_COLOR, + capability.getServerElementColor()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_URL, + capability.getServerBackground()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_SLOGAN, + capability.getServerSlogan()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_LOGO, + capability.getServerLogo()); + contentValues.put(ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION, + capability.getEndToEndEncryption().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION_KEYS_EXIST, + capability.getEndToEndEncryptionKeysExist().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION_API_VERSION, + capability.getEndToEndEncryptionApiVersion().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_DEFAULT, + capability.getServerBackgroundDefault().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_PLAIN, + capability.getServerBackgroundPlain().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_ACTIVITY, + capability.getActivity().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_RICHDOCUMENT, + capability.getRichDocuments().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_MIMETYPE_LIST, + TextUtils.join(",", capability.getRichDocumentsMimeTypeList())); + contentValues.put(ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_OPTIONAL_MIMETYPE_LIST, + TextUtils.join(",", capability.getRichDocumentsOptionalMimeTypeList())); + contentValues.put(ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_DIRECT_EDITING, + capability.getRichDocumentsDirectEditing().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_TEMPLATES, + capability.getRichDocumentsTemplatesAvailable().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_PRODUCT_NAME, + capability.getRichDocumentsProductName()); + contentValues.put(ProviderTableMeta.CAPABILITIES_DIRECT_EDITING_ETAG, + capability.getDirectEditingEtag()); + contentValues.put(ProviderTableMeta.CAPABILITIES_ETAG, capability.getEtag()); + contentValues.put(ProviderTableMeta.CAPABILITIES_USER_STATUS, capability.getUserStatus().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_EMOJI, + capability.getUserStatusSupportsEmoji().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_BUSY, + capability.getUserStatusSupportsBusy().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_LOCKING_VERSION, + capability.getFilesLockingVersion()); + contentValues.put(ProviderTableMeta.CAPABILITIES_ASSISTANT, capability.getAssistant().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_GROUPFOLDERS, capability.getGroupfolders().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_DROP_ACCOUNT, capability.getDropAccount().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_SECURITY_GUARD, capability.getSecurityGuard().getValue()); + + contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS, capability.getForbiddenFilenameCharactersJson()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES, capability.getForbiddenFilenamesJson()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_EXTENSIONS, capability.getForbiddenFilenameExtensionJson()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_BASE_NAMES, capability.getForbiddenFilenameBaseNamesJson()); + contentValues.put(ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES, capability.isWCFEnabled().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT, capability.getFilesDownloadLimit().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT_DEFAULT, capability.getFilesDownloadLimitDefault()); + + contentValues.put(ProviderTableMeta.CAPABILITIES_RECOMMENDATION, capability.getRecommendations().getValue()); + + contentValues.put(ProviderTableMeta.CAPABILITIES_NOTES_FOLDER_PATH, capability.getNotesFolderPath()); + + contentValues.put(ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS, capability.getDefaultPermissions()); + + contentValues.put(ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION, capability.getHasValidSubscription().getValue()); + + contentValues.put(ProviderTableMeta.CAPABILITIES_CLIENT_INTEGRATION_JSON, capability.getClientIntegrationJson()); + + return contentValues; + } + + private boolean capabilityExists(String accountName) { + Cursor cursor = getCapabilityCursorForAccount(accountName); + boolean exists = false; + + if (cursor != null) { + exists = cursor.moveToFirst(); + cursor.close(); + } + + return exists; + } + + private Cursor getCapabilityCursorForAccount(String accountName) { + Cursor cursor = null; + if (getContentResolver() != null) { + cursor = getContentResolver() + .query(ProviderTableMeta.CONTENT_URI_CAPABILITIES, + null, + ProviderTableMeta.CAPABILITIES_ACCOUNT_NAME + "=? ", + new String[]{accountName}, null); + } else { + try { + cursor = getContentProviderClient().query( + ProviderTableMeta.CONTENT_URI_CAPABILITIES, + null, + ProviderTableMeta.CAPABILITIES_ACCOUNT_NAME + "=? ", + new String[]{accountName}, null); + } catch (RemoteException e) { + Log_OC.e(TAG, "Couldn't determine capability existence, assuming non existance: " + e.getMessage(), e); + } + } + + return cursor; + } + + @NonNull + public OCCapability getCapability(User user) { + return getCapability(user.getAccountName()); + } + + @NonNull + public OCCapability getCapability(String accountName) { + OCCapability capability; + Cursor cursor = getCapabilityCursorForAccount(accountName); + + if (cursor.moveToFirst()) { + capability = createCapabilityInstance(cursor); + } else { + capability = new OCCapability(); // return default with all UNKNOWN + } + cursor.close(); + + return capability; + } + + public boolean capabilityExistsForAccount(String accountName) { + Cursor cursor = getCapabilityCursorForAccount(accountName); + + boolean exists = cursor.moveToFirst(); + cursor.close(); + + return exists; + } + + private OCCapability createCapabilityInstance(Cursor cursor) { + OCCapability capability = null; + if (cursor != null) { + capability = new OCCapability(); + capability.setId(getLong(cursor, ProviderTableMeta._ID)); + capability.setAccountName(getString(cursor, ProviderTableMeta.CAPABILITIES_ACCOUNT_NAME)); + capability.setVersionMayor(getInt(cursor, ProviderTableMeta.CAPABILITIES_VERSION_MAYOR)); + capability.setVersionMinor(getInt(cursor, ProviderTableMeta.CAPABILITIES_VERSION_MINOR)); + capability.setVersionMicro(getInt(cursor, ProviderTableMeta.CAPABILITIES_VERSION_MICRO)); + capability.setVersionString(getString(cursor, ProviderTableMeta.CAPABILITIES_VERSION_STRING)); + capability.setVersionEdition(getString(cursor, ProviderTableMeta.CAPABILITIES_VERSION_EDITION)); + capability.setExtendedSupport(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_EXTENDED_SUPPORT)); + capability.setCorePollInterval(getInt(cursor, ProviderTableMeta.CAPABILITIES_CORE_POLLINTERVAL)); + capability.setFilesSharingApiEnabled(getBoolean(cursor, + ProviderTableMeta.CAPABILITIES_SHARING_API_ENABLED)); + capability.setFilesSharingPublicEnabled(getBoolean(cursor, + ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_ENABLED)); + capability.setFilesSharingPublicPasswordEnforced( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED)); + capability.setFilesSharingPublicAskForOptionalPassword( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_ASK_FOR_OPTIONAL_PASSWORD)); + capability.setFilesSharingPublicExpireDateEnabled( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENABLED)); + capability.setFilesSharingPublicExpireDateDays( + getInt(cursor, ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_DAYS)); + capability.setFilesSharingPublicExpireDateEnforced( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENFORCED)); + capability.setFilesSharingPublicSendMail( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_SEND_MAIL)); + capability.setFilesSharingPublicUpload(getBoolean(cursor, + ProviderTableMeta.CAPABILITIES_SHARING_PUBLIC_UPLOAD)); + capability.setFilesSharingUserSendMail(getBoolean(cursor, + ProviderTableMeta.CAPABILITIES_SHARING_USER_SEND_MAIL)); + capability.setFilesSharingResharing(getBoolean(cursor, + ProviderTableMeta.CAPABILITIES_SHARING_RESHARING)); + capability.setFilesSharingFederationOutgoing( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SHARING_FEDERATION_OUTGOING)); + capability.setFilesSharingFederationIncoming( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SHARING_FEDERATION_INCOMING)); + capability.setFilesBigFileChunking(getBoolean(cursor, + ProviderTableMeta.CAPABILITIES_FILES_BIGFILECHUNKING)); + capability.setFilesUndelete(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_FILES_UNDELETE)); + capability.setFilesVersioning(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_FILES_VERSIONING)); + capability.setExternalLinks(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_EXTERNAL_LINKS)); + capability.setServerName(getString(cursor, ProviderTableMeta.CAPABILITIES_SERVER_NAME)); + capability.setServerColor(getString(cursor, ProviderTableMeta.CAPABILITIES_SERVER_COLOR)); + capability.setServerTextColor(getString(cursor, ProviderTableMeta.CAPABILITIES_SERVER_TEXT_COLOR)); + capability.setServerElementColor(getString(cursor, ProviderTableMeta.CAPABILITIES_SERVER_ELEMENT_COLOR)); + capability.setServerBackground(getString(cursor, ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_URL)); + capability.setServerSlogan(getString(cursor, ProviderTableMeta.CAPABILITIES_SERVER_SLOGAN)); + capability.setServerLogo(getString(cursor, ProviderTableMeta.CAPABILITIES_SERVER_LOGO)); + capability.setEndToEndEncryption(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION)); + capability.setEndToEndEncryptionKeysExist( + getBoolean(cursor, + ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION_KEYS_EXIST) + ); + + String e2eVersionString = getString(cursor, ProviderTableMeta.CAPABILITIES_END_TO_END_ENCRYPTION_API_VERSION); + E2EVersion e2EVersion; + if (e2eVersionString == null) { + e2EVersion = E2EVersion.UNKNOWN; + } else { + e2EVersion = E2EVersion.fromValue(e2eVersionString); + } + capability.setEndToEndEncryptionApiVersion(e2EVersion); + + capability.setServerBackgroundDefault( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_DEFAULT)); + capability.setServerBackgroundPlain(getBoolean(cursor, + ProviderTableMeta.CAPABILITIES_SERVER_BACKGROUND_PLAIN)); + capability.setActivity(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_ACTIVITY)); + capability.setRichDocuments(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_RICHDOCUMENT)); + capability.setRichDocumentsDirectEditing( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_DIRECT_EDITING)); + capability.setRichDocumentsTemplatesAvailable( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_TEMPLATES)); + String mimetypes = getString(cursor, ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_MIMETYPE_LIST); + if (mimetypes == null) { + mimetypes = ""; + } + capability.setRichDocumentsMimeTypeList(Arrays.asList(mimetypes.split(","))); + + String optionalMimetypes = getString(cursor, + ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_OPTIONAL_MIMETYPE_LIST); + if (optionalMimetypes == null) { + optionalMimetypes = ""; + } + capability.setRichDocumentsOptionalMimeTypeList(Arrays.asList(optionalMimetypes.split(","))); + capability.setRichDocumentsProductName(getString(cursor, + ProviderTableMeta.CAPABILITIES_RICHDOCUMENT_PRODUCT_NAME)); + capability.setDirectEditingEtag(getString(cursor, ProviderTableMeta.CAPABILITIES_DIRECT_EDITING_ETAG)); + capability.setEtag(getString(cursor, ProviderTableMeta.CAPABILITIES_ETAG)); + capability.setUserStatus(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_USER_STATUS)); + capability.setUserStatusSupportsEmoji( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_EMOJI)); + capability.setUserStatusSupportsBusy( + getBoolean(cursor, ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_BUSY)); + capability.setFilesLockingVersion( + getString(cursor, ProviderTableMeta.CAPABILITIES_FILES_LOCKING_VERSION)); + capability.setAssistant(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_ASSISTANT)); + capability.setGroupfolders(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_GROUPFOLDERS)); + capability.setDropAccount(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_DROP_ACCOUNT)); + capability.setSecurityGuard(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SECURITY_GUARD)); + + capability.setForbiddenFilenameCharactersJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS)); + capability.setForbiddenFilenamesJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES)); + capability.setForbiddenFilenameExtensionJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_EXTENSIONS)); + capability.setForbiddenFilenameBaseNamesJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_BASE_NAMES)); + capability.setWCFEnabled(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES)); + capability.setFilesDownloadLimit(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT)); + capability.setFilesDownloadLimitDefault(getInt(cursor, ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT_DEFAULT)); + + capability.setRecommendations(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_RECOMMENDATION)); + + capability.setNotesFolderPath(getString(cursor, ProviderTableMeta.CAPABILITIES_NOTES_FOLDER_PATH)); + + capability.setDefaultPermissions(getInt(cursor, ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS)); + capability.setHasValidSubscription(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION)); + + capability.setClientIntegrationJson(getString(cursor, ProviderTableMeta.CAPABILITIES_CLIENT_INTEGRATION_JSON)); + } + + return capability; + } + + public void deleteVirtuals(VirtualFolderType type) { + if (getContentResolver() != null) { + getContentResolver().delete(ProviderTableMeta.CONTENT_URI_VIRTUAL, + ProviderTableMeta.VIRTUAL_TYPE + "=?", new String[]{String.valueOf(type)}); + } else { + try { + getContentProviderClient().delete(ProviderTableMeta.CONTENT_URI_VIRTUAL, + ProviderTableMeta.VIRTUAL_TYPE + "=?", + new String[]{String.valueOf(type)}); + } catch (RemoteException e) { + Log_OC.e(TAG, FAILED_TO_INSERT_MSG + e.getMessage(), e); + } + } + } + + public void saveVirtuals(List values) { + Uri contentUriVirtual = ProviderTableMeta.CONTENT_URI_VIRTUAL; + ContentValues[] arrayValues = values.toArray(new ContentValues[0]); + + if (getContentResolver() != null) { + getContentResolver().bulkInsert(contentUriVirtual, arrayValues); + } else { + try { + getContentProviderClient().bulkInsert(contentUriVirtual, arrayValues); + } catch (RemoteException e) { + Log_OC.e(TAG, "saveVirtuals" + e.getMessage(), e); + } + } + } + + public List getAllGalleryItems() { + return getGalleryItems(0, Long.MAX_VALUE); + } + + public List getGalleryItems(long startDate, long endDate) { + Log_OC.d(TAG, "getGalleryItems - start: " + startDate + ", " + endDate); + + List fileEntities = fileDao.getGalleryItems(startDate, endDate, user.getAccountName()); + Log_OC.d(TAG, "getGalleryItems - query complete, list size: " + fileEntities.size()); + + List files = new ArrayList<>(fileEntities.size()); + for (FileEntity fileEntity : fileEntities) { + files.add(createFileInstance(fileEntity)); + } + + Log_OC.d(TAG, "getGalleryItems - finished"); + return files; + } + + public List getVirtualFolderContent(VirtualFolderType type, boolean onlyImages) { + List ocFiles = new ArrayList<>(); + Uri req_uri = ProviderTableMeta.CONTENT_URI_VIRTUAL; + Cursor c; + + if (getContentProviderClient() != null) { + try { + c = getContentProviderClient().query( + req_uri, + null, + ProviderTableMeta.VIRTUAL_TYPE + "=?", + new String[]{String.valueOf(type)}, + null + ); + } catch (RemoteException e) { + Log_OC.e(TAG, e.getMessage(), e); + return ocFiles; + } + } else { + c = getContentResolver().query( + req_uri, + null, + ProviderTableMeta.VIRTUAL_TYPE + "=?", + new String[]{String.valueOf(type)}, + null + ); + } + + if (c != null) { + if (c.moveToFirst()) { + do { + OCFile child = createFileInstanceFromVirtual(c); + + if (child != null) { + ocFiles.add(child); + } + } while (c.moveToNext()); + } + + c.close(); + } + + if (onlyImages) { + List temp = new ArrayList<>(); + + for (OCFile file : ocFiles) { + if (MimeTypeUtil.isImage(file)) { + temp.add(file); + } + } + ocFiles = temp; + } + + if (ocFiles.size() > 0) { + Collections.sort(ocFiles); + } + + return ocFiles; + } + + public void deleteAllFiles() { + String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + "= ? AND " + + ProviderTableMeta.FILE_PATH + "= ?"; + String[] whereArgs = new String[]{user.getAccountName(), OCFile.ROOT_PATH}; + + if (getContentResolver() != null) { + getContentResolver().delete(ProviderTableMeta.CONTENT_URI_DIR, where, whereArgs); + } else { + try { + getContentProviderClient().delete(ProviderTableMeta.CONTENT_URI_DIR, where, whereArgs); + } catch (RemoteException e) { + Log_OC.e(TAG, "Exception in deleteAllFiles for account " + user.getAccountName() + ": " + e.getMessage(), e); + } + } + } + + public String getFolderName(String path) { + return "/" + path.split("/")[1] + "/"; + } + + public String retrieveRemotePathConsideringEncryption(OCFile file) { + if (file == null) { + throw new NullPointerException("file cannot be null"); + } + + String remotePath = file.getRemotePath(); + if (file.isEncrypted()) { + remotePath = getEncryptedRemotePath(file.getRemotePath()); + } + + return remotePath; + } + + public String getEncryptedRemotePath(String decryptedRemotePath) { + String folderName = getFolderName(decryptedRemotePath); + + if (folderName == null) { + throw new NullPointerException("folderName cannot be null"); + } + + OCFile folder = getFileByDecryptedRemotePath(folderName); + List files = getAllFilesRecursivelyInsideFolder(folder); + List> decryptedFileNamesAndEncryptedRemotePaths = getDecryptedFileNamesAndEncryptedRemotePaths(files); + + String decryptedFileName = decryptedRemotePath.substring(decryptedRemotePath.lastIndexOf('/') + 1); + + for (Pair item : decryptedFileNamesAndEncryptedRemotePaths) { + if (item.getFirst().equals(decryptedFileName)) { + return item.getSecond(); + } + } + + return null; + } + + @SuppressFBWarnings("OCP") + private List> getDecryptedFileNamesAndEncryptedRemotePaths(List fileList) { + List> result = new ArrayList<>(); + + for (OCFile file : fileList) { + if (file.isEncrypted()) { + Pair fileNameAndEncryptedRemotePath = new Pair<>(file.getDecryptedFileName(), file.getRemotePath()); + result.add(fileNameAndEncryptedRemotePath); + } + } + + return result; + } + + public void removeLocalFiles(User user, FileDataStorageManager storageManager) { + File tempDir = new File(FileStorageUtils.getTemporalPath(user.getAccountName())); + File saveDir = new File(FileStorageUtils.getSavePath(user.getAccountName())); + FileStorageUtils.deleteRecursively(tempDir, storageManager); + FileStorageUtils.deleteRecursively(saveDir, storageManager); + } + + public List getAllFiles() { + // TODO - Apparently this method is used only by tests + List fileEntities = fileDao.getAllFiles(user.getAccountName()); + List folderContent = new ArrayList<>(fileEntities.size()); + + for (FileEntity fileEntity : fileEntities) { + folderContent.add(createFileInstance(fileEntity)); + } + + return folderContent; + } + + private String getString(Cursor cursor, String columnName) { + return cursor.getString(cursor.getColumnIndexOrThrow(columnName)); + } + + private int getInt(Cursor cursor, String columnName) { + return cursor.getInt(cursor.getColumnIndexOrThrow(columnName)); + } + + private long getLong(Cursor cursor, String columnName) { + return cursor.getLong(cursor.getColumnIndexOrThrow(columnName)); + } + + private CapabilityBooleanType getBoolean(Cursor cursor, String columnName) { + return CapabilityBooleanType.fromValue(cursor.getInt(cursor.getColumnIndexOrThrow(columnName))); + } + + public ContentResolver getContentResolver() { + return this.contentResolver; + } + + public ContentProviderClient getContentProviderClient() { + return this.contentProviderClient; + } + + public User getUser() { + return user; + } + + public OCFile getDefaultRootPath() { + return new OCFile(OCFile.ROOT_PATH); + } + + public List getFilesWithSyncConflict(User user) { + List fileEntities = fileDao.getFilesWithSyncConflict(user.getAccountName()); + List files = new ArrayList<>(fileEntities.size()); + + for (FileEntity fileEntity : fileEntities) { + files.add(createFileInstance(fileEntity)); + } + + return files; + } + + public List getInternalTwoWaySyncFolders(User user) { + List fileEntities = fileDao.getInternalTwoWaySyncFolders(user.getAccountName()); + List files = new ArrayList<>(fileEntities.size()); + + for (FileEntity fileEntity : fileEntities) { + OCFile file = createFileInstance(fileEntity); + if (file.isFolder() && !file.isRootDirectory()) { + files.add(file); + } + } + + return files; + } + + public boolean isPartOfInternalTwoWaySync(OCFile file) { + if (file.isInternalFolderSync()) { + return true; + } + + while (file != null && !OCFile.ROOT_PATH.equals(file.getDecryptedRemotePath())) { + if (file.isInternalFolderSync()) { + return true; + } + file = getFileById(file.getParentId()); + } + return false; + } + + @Nullable + public FileEntity getFileEntity(OCFile file) { + if (file == null) { + return null; + } + + return fileDao.getFileById(file.getFileId()); + } + + public void updateFileEntity(@NonNull FileEntity entity) { + fileDao.update(entity); + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java b/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java new file mode 100644 index 000000000000..48f212e8380e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel; + +import android.content.ContentResolver; + +import com.owncloud.android.db.ProviderMeta; +import com.owncloud.android.lib.common.utils.Log_OC; + +/** + * Provider for stored filesystem data. + */ +public class FilesystemDataProvider { + + static private final String TAG = FilesystemDataProvider.class.getSimpleName(); + + private final ContentResolver contentResolver; + + public FilesystemDataProvider(ContentResolver contentResolver) { + if (contentResolver == null) { + Log_OC.e(TAG, "couldn't be able constructed, contentResolver is null"); + throw new IllegalArgumentException("Cannot create an instance with a NULL contentResolver"); + } + this.contentResolver = contentResolver; + } + + public int deleteAllEntriesForSyncedFolder(String syncedFolderId) { + Log_OC.d(TAG, "deleteAllEntriesForSyncedFolder called, ID: " + syncedFolderId); + + return contentResolver.delete( + ProviderMeta.ProviderTableMeta.CONTENT_URI_FILESYSTEM, + ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID + " = ?", + new String[]{syncedFolderId}); + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ForegroundServiceType.kt b/app/src/main/java/com/owncloud/android/datamodel/ForegroundServiceType.kt new file mode 100644 index 000000000000..46bb5ee74f8a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/ForegroundServiceType.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi + +/** + * Enum to specify the type of foreground service. + * Use this enum when starting a foreground service to indicate its purpose. + * Note: Foreground service type is not available for older Android versions. + * This wrapper is designed for compatibility on those versions. + */ +enum class ForegroundServiceType { + DataSync, + MediaPlayback; + + @RequiresApi(Build.VERSION_CODES.Q) + fun getId(): Int = if (this == DataSync) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/GalleryItems.kt b/app/src/main/java/com/owncloud/android/datamodel/GalleryItems.kt new file mode 100644 index 000000000000..44cb3175beae --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/GalleryItems.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import com.owncloud.android.utils.DisplayUtils + +data class GalleryItems(val date: Long, val rows: List) { + override fun toString(): String { + val month = DisplayUtils.getDateByPattern( + date, + DisplayUtils.MONTH_PATTERN + ) + val year = DisplayUtils.getDateByPattern( + date, + DisplayUtils.YEAR_PATTERN + ) + return "$month/$year with $rows rows" + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt b/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt new file mode 100644 index 000000000000..6e99de382fae --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import com.nextcloud.utils.OCFileUtils + +data class GalleryRow(val files: List, val defaultHeight: Int, val defaultWidth: Int) { + fun getMaxHeight(): Float = files.maxOfOrNull { + OCFileUtils.getImageSize(it, defaultHeight.toFloat()).second.toFloat() + } ?: 0f +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/MediaFolder.kt b/app/src/main/java/com/owncloud/android/datamodel/MediaFolder.kt new file mode 100644 index 000000000000..0ebb02dbe985 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/MediaFolder.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 Nextcloud + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +/** + * Business object representing a media folder with all information that are gathered via media queries. + */ +class MediaFolder { + /** name of the folder. */ + @JvmField + var folderName: String? = null + + /** absolute path of the folder. */ + @JvmField + var absolutePath: String? = null + + /** list of file paths of the folder's content */ + @JvmField + var filePaths: List = ArrayList() + + /** total number of files in the media folder. */ + @JvmField + var numberOfFiles: Long = 0 + + /** type of media folder. */ + @JvmField + var type: MediaFolderType? = null +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/MediaFolderType.kt b/app/src/main/java/com/owncloud/android/datamodel/MediaFolderType.kt new file mode 100644 index 000000000000..8b98a1893fa6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/MediaFolderType.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.util.SparseArray + +/** + * Types of media folder. + */ +enum class MediaFolderType(@JvmField val id: Int) { + CUSTOM(0), + IMAGE(1), + VIDEO(2); + + companion object { + private val reverseMap = SparseArray(3) + + init { + reverseMap.put(CUSTOM.id, CUSTOM) + reverseMap.put(IMAGE.id, IMAGE) + reverseMap.put(VIDEO.id, VIDEO) + } + + @JvmStatic + fun getById(id: Int?): MediaFolderType = reverseMap[id!!] + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/MediaFoldersModel.kt b/app/src/main/java/com/owncloud/android/datamodel/MediaFoldersModel.kt new file mode 100644 index 000000000000..dc08c09ef237 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/MediaFoldersModel.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Android client application + * + * @author Mario Danic + * @author Andy Scherzinger + * Copyright (C) 2018 Mario Danic + * Copyright (C) 2018 Andy Scherzinger + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +class MediaFoldersModel(var imageMediaFolders: List, var videoMediaFolders: List) diff --git a/app/src/main/java/com/owncloud/android/datamodel/MediaProvider.java b/app/src/main/java/com/owncloud/android/datamodel/MediaProvider.java new file mode 100644 index 000000000000..b4833d456d24 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/MediaProvider.java @@ -0,0 +1,260 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 Nextcloud + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore; + +import com.owncloud.android.MainApp; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.PermissionUtil; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +import androidx.appcompat.app.AppCompatActivity; + +/** + * Media queries to gain access to media lists for the device. + */ +public final class MediaProvider { + private static final String TAG = MediaProvider.class.getSimpleName(); + + // fixed query parameters + private static final Uri IMAGES_MEDIA_URI = android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + private static final String[] FILE_PROJECTION = new String[]{MediaStore.MediaColumns.DATA}; + private static final String IMAGES_FILE_SELECTION = MediaStore.Images.Media.BUCKET_ID + "="; + private static final String[] IMAGES_FOLDER_PROJECTION = {MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME}; + private static final String IMAGES_FOLDER_SORT_COLUMN = MediaStore.Images.Media.BUCKET_DISPLAY_NAME; + private static final String IMAGES_SORT_DIRECTION = ContentResolverHelper.SORT_DIRECTION_ASCENDING; + + private static final String[] VIDEOS_FOLDER_PROJECTION = {MediaStore.Video.Media.BUCKET_ID, + MediaStore.Video.Media.BUCKET_DISPLAY_NAME}; + + private MediaProvider() { + // utility class -> private constructor + } + + /** + * Getting All Images Paths. + * + * @param contentResolver the content resolver + * @param itemLimit the number of media items (usually images) to be returned per media folder. + * @return list with media folders + */ + public static List getImageFolders(ContentResolver contentResolver, + int itemLimit, + @Nullable final AppCompatActivity activity, + boolean getWithoutActivity) { + // check permissions + checkPermissions(activity); + + // query media/image folders + Cursor cursorFolders = null; + if (activity != null && PermissionUtil.checkStoragePermission(activity.getApplicationContext()) + || getWithoutActivity) { + cursorFolders = ContentResolverHelper.queryResolver(contentResolver, IMAGES_MEDIA_URI, + IMAGES_FOLDER_PROJECTION, null, null, + IMAGES_FOLDER_SORT_COLUMN, IMAGES_SORT_DIRECTION, null); + } + + List mediaFolders = new ArrayList<>(); + String dataPath = MainApp.getStoragePath() + File.separator + MainApp.getDataFolder(); + + if (cursorFolders != null) { + Cursor cursorImages; + + Map uniqueFolders = new HashMap<>(); + + // since sdk 29 we have to manually distinct on bucket id + while (cursorFolders.moveToNext()) { + uniqueFolders.put(cursorFolders.getString( + cursorFolders.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_ID)), + cursorFolders.getString( + cursorFolders.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)) + ); + } + cursorFolders.close(); + + for (Map.Entry folder : uniqueFolders.entrySet()) { + MediaFolder mediaFolder = new MediaFolder(); + + mediaFolder.type = MediaFolderType.IMAGE; + mediaFolder.folderName = folder.getValue(); + mediaFolder.filePaths = new ArrayList<>(); + + // query images + cursorImages = ContentResolverHelper.queryResolver(contentResolver, + IMAGES_MEDIA_URI, + FILE_PROJECTION, + IMAGES_FILE_SELECTION + folder.getKey(), + null, + MediaStore.Images.Media.DATE_TAKEN, + ContentResolverHelper.SORT_DIRECTION_DESCENDING, + itemLimit); + Log_OC.d(TAG, "Reading images for " + mediaFolder.folderName); + + if (cursorImages != null) { + String filePath; + int imageCount = 0; + while (cursorImages.moveToNext() && imageCount < itemLimit) { + filePath = cursorImages.getString(cursorImages.getColumnIndexOrThrow( + MediaStore.MediaColumns.DATA)); + + // check if valid path and file exists + if (isValidAndExistingFilePath(filePath)) { + mediaFolder.filePaths.add(filePath); + mediaFolder.absolutePath = filePath.substring(0, filePath.lastIndexOf('/')); + } + // ensure we don't go over the limit due to faulty android implementations + imageCount++; + } + cursorImages.close(); + + // only do further work if folder is not within the Nextcloud app itself + if (isFolderOutsideOfAppPath(dataPath, mediaFolder)) { + + // count images + Cursor count = contentResolver.query( + IMAGES_MEDIA_URI, + FILE_PROJECTION, + IMAGES_FILE_SELECTION + folder.getKey(), + null, + null); + + if (count != null) { + mediaFolder.numberOfFiles = count.getCount(); + count.close(); + } + + mediaFolders.add(mediaFolder); + } + } + } + } + + return mediaFolders; + } + + private static boolean isFolderOutsideOfAppPath(String dataPath, MediaFolder mediaFolder) { + return mediaFolder.absolutePath != null && !mediaFolder.absolutePath.startsWith(dataPath); + } + + private static boolean isValidAndExistingFilePath(String filePath) { + return filePath != null && filePath.lastIndexOf('/') > 0 && new File(filePath).exists(); + } + + private static void checkPermissions(@Nullable AppCompatActivity activity) { + if (activity != null && + !PermissionUtil.checkStoragePermission(activity.getApplicationContext())) { + PermissionUtil.requestStoragePermissionIfNeeded(activity); + } + } + + public static List getVideoFolders(ContentResolver contentResolver, + int itemLimit, + @Nullable final AppCompatActivity activity, + boolean getWithoutActivity) { + // check permissions + checkPermissions(activity); + + // query media/image folders + Cursor cursorFolders = null; + if ((activity != null && PermissionUtil.checkStoragePermission(activity.getApplicationContext())) + || getWithoutActivity) { + cursorFolders = contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEOS_FOLDER_PROJECTION, + null, null, null); + } + + List mediaFolders = new ArrayList<>(); + String dataPath = MainApp.getStoragePath() + File.separator + MainApp.getDataFolder(); + + if (cursorFolders != null) { + Cursor cursorVideos; + + Map uniqueFolders = new HashMap<>(); + + // since sdk 29 we have to manually distinct on bucket id + while (cursorFolders.moveToNext()) { + uniqueFolders.put(cursorFolders.getString( + cursorFolders.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_ID)), + cursorFolders.getString( + cursorFolders.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME)) + ); + } + cursorFolders.close(); + + for (Map.Entry folder : uniqueFolders.entrySet()) { + MediaFolder mediaFolder = new MediaFolder(); + mediaFolder.type = MediaFolderType.VIDEO; + mediaFolder.folderName = folder.getValue(); + mediaFolder.filePaths = new ArrayList<>(); + + // query videos + cursorVideos = ContentResolverHelper.queryResolver(contentResolver, + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + FILE_PROJECTION, + MediaStore.Video.Media.BUCKET_ID + "=" + folder.getKey(), + null, + MediaStore.Video.Media.DATE_TAKEN, + ContentResolverHelper.SORT_DIRECTION_DESCENDING, + itemLimit); + + Log_OC.d(TAG, "Reading videos for " + mediaFolder.folderName); + + if (cursorVideos != null) { + String filePath; + int videoCount = 0; + while (cursorVideos.moveToNext() && videoCount < itemLimit) { + filePath = cursorVideos.getString(cursorVideos.getColumnIndexOrThrow( + MediaStore.MediaColumns.DATA)); + + if (filePath != null) { + mediaFolder.filePaths.add(filePath); + mediaFolder.absolutePath = filePath.substring(0, filePath.lastIndexOf('/')); + } + // ensure we don't go over the limit due to faulty android implementations + videoCount++; + } + cursorVideos.close(); + + // only do further work if folder is not within the Nextcloud app itself + if (isFolderOutsideOfAppPath(dataPath, mediaFolder)) { + + // count images + Cursor count = contentResolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + FILE_PROJECTION, + MediaStore.Video.Media.BUCKET_ID + "=" + folder.getKey(), + null, + null); + + if (count != null) { + mediaFolder.numberOfFiles = count.getCount(); + count.close(); + } + + mediaFolders.add(mediaFolder); + } + } + } + cursorFolders.close(); + } + + return mediaFolders; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java new file mode 100644 index 000000000000..7aaf0e05924f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java @@ -0,0 +1,1178 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey Vilas + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2018 Mario Danic + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2012-2016 David A. Velasco + * SPDX-FileCopyrightText: 2012 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.datamodel; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.nextcloud.utils.BuildHelper; +import com.nextcloud.utils.extensions.StringExtensionsKt; +import com.owncloud.android.R; +import com.owncloud.android.lib.common.network.WebdavEntry; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.model.FileLockType; +import com.owncloud.android.lib.resources.files.model.GeoLocation; +import com.owncloud.android.lib.resources.files.model.ImageDimension; +import com.owncloud.android.lib.resources.files.model.ServerFileInterface; +import com.owncloud.android.lib.resources.shares.ShareeUser; +import com.owncloud.android.lib.resources.tags.Tag; +import com.owncloud.android.utils.MimeType; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import third_parties.daveKoeller.AlphanumComparator; + +public class OCFile implements Parcelable, Comparable, ServerFileInterface { + + public final static String PERMISSION_CAN_RESHARE = "R"; + public final static String PERMISSION_SHARED = "S"; + public final static String PERMISSION_MOUNTED = "M"; + public final static String PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER = "C"; + public final static String PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER = "K"; + public final static String PERMISSION_CAN_READ = "G"; + public final static String PERMISSION_CAN_WRITE = "W"; + public final static String PERMISSION_CAN_DELETE_OR_LEAVE_SHARE = "D"; + public final static String PERMISSION_CAN_RENAME = "N"; + public final static String PERMISSION_CAN_MOVE = "V"; + public final static String PERMISSION_CAN_CREATE_FILE_AND_FOLDER = PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER + PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER; + + private final static int MAX_FILE_SIZE_FOR_IMMEDIATE_PREVIEW_BYTES = 1024000; + + public static final String PATH_SEPARATOR = "/"; + public static final String ROOT_PATH = PATH_SEPARATOR; + + private static final String TAG = OCFile.class.getSimpleName(); + + private long fileId; // android internal ID of the file + private long parentId; + private long fileLength; + private long creationTimestamp; // UNIX timestamp of the time the file was created + private long modificationTimestamp; // UNIX timestamp of the file modification time + private long uploadTimestamp; + /** + * UNIX timestamp of the modification time, corresponding to the value returned by the server in the last + * synchronization of THE CONTENTS of this file. + */ + private long modificationTimestampAtLastSyncForData; + private long firstShareTimestamp; // UNIX timestamp of the first share time + private String remotePath; + private String decryptedRemotePath; + private String localPath; + private String mimeType; + private boolean needsUpdatingWhileSaving; + private long lastSyncDateForProperties; + private long lastSyncDateForData; + private boolean previewAvailable; + private String livePhoto; + public OCFile livePhotoVideo; + private String etag; + private String etagOnServer; + private boolean sharedViaLink; + private String permissions; + private long localId; // unique fileId for the file within the instance + private String remoteId; // The fileid namespaced by the instance fileId, globally unique + private boolean updateThumbnailNeeded; + private boolean downloading; + private String etagInConflict; // Only saves file etag in the server, when there is a conflict + private boolean sharedWithSharee; + private boolean favorite; + private boolean hidden; + private boolean encrypted; + private WebdavEntry.MountType mountType; + private int unreadCommentsCount; + private String ownerId; + private String ownerDisplayName; + String note; + private List sharees; + private String richWorkspace; + private boolean locked; + @Nullable + private FileLockType lockType; + @Nullable + private String lockOwnerId; + @Nullable + private String lockOwnerDisplayName; + @Nullable + private String lockOwnerEditor; + private long lockTimestamp; + private long lockTimeout; + @Nullable + private String lockToken; + @Nullable + private ImageDimension imageDimension; + private long e2eCounter = -1; + @Nullable + private GeoLocation geolocation; + private List tags = new ArrayList<>(); + private Long internalFolderSyncTimestamp = -1L; + private String internalFolderSyncResult = ""; + + // region Recommend files variables + private boolean recommendedFile = false; + private String reason = ""; + // endregion + + /** + * URI to the local path of the file contents, if stored in the device; cached after first call to + * {@link #getStorageUri()} + */ + private Uri localUri; + + + /** + * Exportable URI to the local path of the file contents, if stored in the device. + *

+ * Cached after first call, until changed. + */ + private Uri exposedFileUri; + + /** + * Create new {@link OCFile} with given path. + *

+ * The path received must be URL-decoded. Path separator must be OCFile.PATH_SEPARATOR, and it must be the first character in 'path'. + * + * @param path The remote path of the file. + */ + public OCFile(String path) { + resetData(); + needsUpdatingWhileSaving = false; + if (TextUtils.isEmpty(path) || !path.startsWith(PATH_SEPARATOR)) { + throw new IllegalArgumentException("Trying to create a OCFile with a non valid remote path: " + path); + } + remotePath = path; + } + + /** + * Reconstruct from parcel + * + * @param source The source parcel + */ + private OCFile(Parcel source) { + fileId = source.readLong(); + parentId = source.readLong(); + fileLength = source.readLong(); + uploadTimestamp = source.readLong(); + creationTimestamp = source.readLong(); + modificationTimestamp = source.readLong(); + modificationTimestampAtLastSyncForData = source.readLong(); + remotePath = source.readString(); + decryptedRemotePath = source.readString(); + localPath = source.readString(); + mimeType = source.readString(); + needsUpdatingWhileSaving = source.readInt() == 0; + lastSyncDateForProperties = source.readLong(); + lastSyncDateForData = source.readLong(); + etag = source.readString(); + etagOnServer = source.readString(); + sharedViaLink = source.readInt() == 1; + permissions = source.readString(); + localId = source.readLong(); + remoteId = source.readString(); + updateThumbnailNeeded = source.readInt() == 1; + downloading = source.readInt() == 1; + etagInConflict = source.readString(); + sharedWithSharee = source.readInt() == 1; + favorite = source.readInt() == 1; + hidden = source.readInt() == 1; + encrypted = source.readInt() == 1; + ownerId = source.readString(); + ownerDisplayName = source.readString(); + mountType = (WebdavEntry.MountType) source.readSerializable(); + richWorkspace = source.readString(); + previewAvailable = source.readInt() == 1; + firstShareTimestamp = source.readLong(); + locked = source.readInt() == 1; + lockType = FileLockType.fromValue(source.readInt()); + lockOwnerId = source.readString(); + lockOwnerDisplayName = source.readString(); + lockOwnerEditor = source.readString(); + lockTimestamp = source.readLong(); + lockTimeout = source.readLong(); + lockToken = source.readString(); + livePhoto = source.readString(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(fileId); + dest.writeLong(parentId); + dest.writeLong(fileLength); + dest.writeLong(uploadTimestamp); + dest.writeLong(creationTimestamp); + dest.writeLong(modificationTimestamp); + dest.writeLong(modificationTimestampAtLastSyncForData); + dest.writeString(remotePath); + dest.writeString(decryptedRemotePath); + dest.writeString(localPath); + dest.writeString(mimeType); + dest.writeInt(needsUpdatingWhileSaving ? 1 : 0); + dest.writeLong(lastSyncDateForProperties); + dest.writeLong(lastSyncDateForData); + dest.writeString(etag); + dest.writeString(etagOnServer); + dest.writeInt(sharedViaLink ? 1 : 0); + dest.writeString(permissions); + dest.writeLong(localId); + dest.writeString(remoteId); + dest.writeInt(updateThumbnailNeeded ? 1 : 0); + dest.writeInt(downloading ? 1 : 0); + dest.writeString(etagInConflict); + dest.writeInt(sharedWithSharee ? 1 : 0); + dest.writeInt(favorite ? 1 : 0); + dest.writeInt(hidden ? 1 : 0); + dest.writeInt(encrypted ? 1 : 0); + dest.writeString(ownerId); + dest.writeString(ownerDisplayName); + dest.writeSerializable(mountType); + dest.writeString(richWorkspace); + dest.writeInt(previewAvailable ? 1 : 0); + dest.writeLong(firstShareTimestamp); + dest.writeInt(locked ? 1 : 0); + dest.writeInt(lockType != null ? lockType.getValue() : -1); + dest.writeString(lockOwnerId); + dest.writeString(lockOwnerDisplayName); + dest.writeString(lockOwnerEditor); + dest.writeLong(lockTimestamp); + dest.writeLong(lockTimeout); + dest.writeString(lockToken); + dest.writeString(livePhoto); + } + + public String getLinkedFileIdForLivePhoto() { + return livePhoto; + } + + public void setLivePhoto(String livePhoto) { + this.livePhoto = livePhoto; + } + + public void setDecryptedRemotePath(String path) { + decryptedRemotePath = path; + } + + /** + * Use decrypted remote path for every local file operation Use encrypted remote path for every dav related + * operation + */ + public String getDecryptedRemotePath() { + // Fallback + // TODO test without, on a new created folder + if (!isEncrypted() && decryptedRemotePath == null) { + decryptedRemotePath = remotePath; + } + + if (isFolder()) { + if (decryptedRemotePath.endsWith(PATH_SEPARATOR)) { + return decryptedRemotePath; + } else { + return decryptedRemotePath + PATH_SEPARATOR; + } + } else { + if (decryptedRemotePath == null) { + // last fallback + return remotePath; + } else { + return decryptedRemotePath; + } + } + } + + /** + * Returns the remote path of the file on Nextcloud + * (this might be an encrypted file path, if E2E is used) + *

+ * Use decrypted remote path for every local file operation. + * Use remote path for every dav related operation + * + * @return The remote path to the file + */ + public String getRemotePath() { + if (isFolder()) { + if (remotePath.endsWith(PATH_SEPARATOR)) { + return remotePath; + } else { + return remotePath + PATH_SEPARATOR; + } + } else { + return remotePath; + } + } + + /** + * Can be used to check, whether or not this file exists in the database already + * + * @return true, if the file exists in the database + */ + public boolean fileExists() { + return fileId != -1; + } + + /** + * Use this to find out if this file is a folder. + * + * @return true if it is a folder + */ + public boolean isFolder() { + return MimeType.DIRECTORY.equals(mimeType) || MimeType.WEBDAV_FOLDER.equals(mimeType); + } + + + /** + * Sets mimetype to folder and returns this file + * Only for testing + * + * @return OCFile this file + */ + public OCFile setFolder() { + setMimeType(MimeType.DIRECTORY); + return this; + } + + /** + * Use this to check if this file is available locally + * + * @return true if it is + */ + public boolean isDown() { + return !isFolder() && existsOnDevice(); + } + + /** + * Use this to check if this file or folder is available locally + * + * @return true if it is + */ + public boolean existsOnDevice() { + if (!TextUtils.isEmpty(localPath)) { + return new File(localPath).exists(); + } + return false; + } + + /** + * The path, where the file is stored locally + * + * @return The local path to the file + */ + public String getStoragePath() { + return localPath; + } + + /** + * The URI to the file contents, if stored locally + * + * @return A URI to the local copy of the file, or NULL if not stored in the device + */ + public Uri getStorageUri() { + if (TextUtils.isEmpty(localPath)) { + return null; + } + if (localUri == null) { + Uri.Builder builder = new Uri.Builder(); + builder.scheme(ContentResolver.SCHEME_FILE); + builder.path(localPath); + localUri = builder.build(); + } + return localUri; + } + + public Uri getExposedFileUri(Context context) { + if (TextUtils.isEmpty(localPath)) { + return null; + } + + if (exposedFileUri == null) { + try { + exposedFileUri = FileProvider.getUriForFile( + context, + context.getString(R.string.file_provider_authority), + new File(localPath)); + } catch (IllegalArgumentException ex) { + Log_OC.d(TAG, "Given File is outside the paths supported by the provider"); + } + } + + return exposedFileUri; + } + + /** + * Can be used to set the path where the file is stored + * + * @param storage_path to set + */ + public void setStoragePath(String storage_path) { + if (storage_path == null) { + localPath = null; + } else { + localPath = storage_path.replaceAll("//", "/"); + + if (isFolder() && !localPath.endsWith("/")) { + localPath = localPath + "/"; + } + } + localUri = null; + exposedFileUri = null; + } + + /** + * Returns the decrypted filename and "/" for the root directory + * + * @return The name of the file + */ + public String getFileName() { + return getDecryptedFileName(); + } + + /** + * Returns the decrypted filename and "/" for the root directory + * + * @return The name of the file + */ + public String getDecryptedFileName() { + File f = new File(getDecryptedRemotePath()); + return f.getName().length() == 0 ? ROOT_PATH : f.getName(); + } + + /** + * Returns the encrypted filename and "/" for the root directory + * + * @return The name of the file + */ + public String getEncryptedFileName() { + File f = new File(remotePath); + return f.getName().length() == 0 ? ROOT_PATH : f.getName(); + } + + /** + * Sets the name of the file + *

+ * Does nothing if the new name is null, empty or includes "/" ; or if the file is the root + * directory + */ + public void setFileName(String name) { + Log_OC.d(TAG, "OCFile name changing from " + remotePath); + if (!TextUtils.isEmpty(name) && !name.contains(PATH_SEPARATOR) && !ROOT_PATH.equals(remotePath)) { + String parent = new File(this.getRemotePath()).getParent(); + if (parent != null) { + parent = parent.endsWith(PATH_SEPARATOR) ? parent : parent + PATH_SEPARATOR; + remotePath = parent + name; + if (isFolder()) { + remotePath += PATH_SEPARATOR; + } + Log_OC.d(TAG, "OCFile name changed to " + remotePath); + } + } + } + + /** + * Used internally. Reset all file properties + */ + private void resetData() { + fileId = -1; + remotePath = null; + decryptedRemotePath = null; + parentId = 0; + localPath = null; + mimeType = null; + fileLength = 0; + uploadTimestamp = 0; + creationTimestamp = 0; + modificationTimestamp = 0; + modificationTimestampAtLastSyncForData = 0; + lastSyncDateForProperties = 0; + lastSyncDateForData = 0; + needsUpdatingWhileSaving = false; + etag = null; + etagOnServer = null; + sharedViaLink = false; + permissions = null; + localId = -1; + remoteId = null; + updateThumbnailNeeded = false; + downloading = false; + etagInConflict = null; + sharedWithSharee = false; + favorite = false; + hidden = false; + encrypted = false; + mountType = WebdavEntry.MountType.INTERNAL; + richWorkspace = ""; + firstShareTimestamp = 0; + locked = false; + lockType = null; + lockOwnerId = null; + lockOwnerDisplayName = null; + lockOwnerEditor = null; + lockTimestamp = 0; + lockTimeout = 0; + lockToken = null; + livePhoto = null; + imageDimension = null; + } + + /** + * get remote path of parent file + * + * @return remote path + */ + public String getParentRemotePath() { + String parentPath = new File(this.getRemotePath()).getParent(); + if (parentPath != null) { + return parentPath.endsWith(PATH_SEPARATOR) ? parentPath : parentPath + PATH_SEPARATOR; + } else { + return null; + } + } + + @Override + public int describeContents() { + return super.hashCode(); + } + + @Override + public int compareTo(@NonNull OCFile another) { + if (isFolder() && another.isFolder()) { + return AlphanumComparator.compare(this, another); + } else if (isFolder()) { + return -1; + } else if (another.isFolder()) { + return 1; + } + return AlphanumComparator.compare(this, another); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + OCFile ocFile = (OCFile) o; + + return fileId == ocFile.fileId && parentId == ocFile.parentId; + } + + @Override + public int hashCode() { + return Objects.hash(fileId,parentId); + } + + @NonNull + @Override + public String toString() { + String asString = "[id=%s, name=%s, mime=%s, downloaded=%s, local=%s, remote=%s, " + + "parentId=%s, etag=%s, favourite=%s]"; + return String.format(asString, + fileId, + getFileName(), + mimeType, + isDown(), + localPath, + remotePath, + parentId, + etag, + favorite); + } + + public void setEtag(String etag) { + this.etag = etag != null ? etag : ""; + } + + public void setEtagOnServer(String etag) { + this.etagOnServer = etag != null ? etag : ""; + } + + public long getLocalModificationTimestamp() { + if (!TextUtils.isEmpty(localPath)) { + File f = new File(localPath); + return f.lastModified(); + } + return 0; + } + + /** + * @return 'True' if the file is hidden + */ + public boolean isHidden() { + return !TextUtils.isEmpty(getFileName()) && getFileName().charAt(0) == '.'; + } + + /** + * unique fileId for the file within the instance + */ + @SuppressFBWarnings("STT") + public long getLocalId() { + if (localId > 0) { + return localId; + } else if (remoteId != null && remoteId.length() > 8) { + return Long.parseLong(remoteId.substring(0, 8).replaceAll("^0*", "")); + } else { + return -1; + } + } + + public boolean isInConflict() { + return !TextUtils.isEmpty(etagInConflict); + } + + public boolean isSharedWithMe() { + return hasPermission(PERMISSION_SHARED); + } + + public boolean canReshare() { + return hasPermission(PERMISSION_CAN_RESHARE); + } + + public boolean canCreateFileAndFolder() { + return hasPermission(PERMISSION_CAN_CREATE_FILE_AND_FOLDER); + } + + public boolean mounted() { + return hasPermission(PERMISSION_MOUNTED); + } + + public boolean canRead() { + return hasPermission(PERMISSION_CAN_READ); + } + + public boolean canCreateFileInsideFolder() { + return hasPermission(PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER); + } + + public boolean canCreateFolderInsideFolder() { + return hasPermission(PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER); + } + + /** + * Determines whether the current account has the ability to delete the file or leave the share. + * + *

+ * - If the file is shared with the current account (i.e., the user is the recipient), + * the user cannot delete the file itself but can leave the shared file. + *

+ * - If the file is belongs to the current user. User can delete the file. + * + * @return true if the user is allowed to either delete or leave the share; false otherwise. + */ + public boolean canDeleteOrLeaveShare() { + return hasPermission(PERMISSION_CAN_DELETE_OR_LEAVE_SHARE); + } + + public boolean canRename() { + return hasPermission(PERMISSION_CAN_RENAME); + } + + public boolean canWrite() { + return hasPermission(PERMISSION_CAN_WRITE); + } + + public boolean canMove() { + return hasPermission(PERMISSION_CAN_MOVE); + } + + private boolean hasPermission(String permission) { + String permissions = getPermissions(); + return permissions != null && permissions.contains(permission); + } + + public Integer getFileOverlayIconId(boolean isAutoUploadFolder) { + if (WebdavEntry.MountType.GROUP == mountType || mounted()) { + return R.drawable.ic_folder_overlay_account_group; + } else if (sharedViaLink && !encrypted) { + return R.drawable.ic_folder_overlay_link; + } else if (isSharedWithMe() || sharedWithSharee) { + return R.drawable.ic_folder_overlay_share; + } else if (encrypted) { + return R.drawable.ic_folder_overlay_key; + } else if (WebdavEntry.MountType.EXTERNAL == mountType) { + return R.drawable.ic_folder_overlay_external; + } else if (locked) { + return R.drawable.ic_folder_overlay_lock; + } else if (isAutoUploadFolder) { + return R.drawable.ic_folder_overlay_upload; + } else { + return null; + } + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator<>() { + + @Override + public OCFile createFromParcel(Parcel source) { + return new OCFile(source); + } + + @Override + public OCFile[] newArray(int size) { + return new OCFile[size]; + } + }; + + /** + * Android's internal ID of the file + */ + public long getFileId() { + return this.fileId; + } + + public long getParentId() { + return this.parentId; + } + + public long getFileLength() { + return this.fileLength; + } + + public boolean isFileEligibleForImmediatePreview() { + return fileLength <= MAX_FILE_SIZE_FOR_IMMEDIATE_PREVIEW_BYTES; + } + + public long getCreationTimestamp() { + return this.creationTimestamp; + } + + /** + * @return unix timestamp in milliseconds + */ + public long getModificationTimestamp() { + return this.modificationTimestamp; + } + + public long getModificationTimestampAtLastSyncForData() { + return this.modificationTimestampAtLastSyncForData; + } + + public String getMimeType() { + return this.mimeType; + } + + public boolean isNeedsUpdatingWhileSaving() { + return this.needsUpdatingWhileSaving; + } + + public long getLastSyncDateForProperties() { + return this.lastSyncDateForProperties; + } + + public long getLastSyncDateForData() { + return this.lastSyncDateForData; + } + + public boolean isPreviewAvailable() { + return this.previewAvailable; + } + + public String getEtag() { + return this.etag; + } + + public String getEtagOnServer() { + return this.etagOnServer; + } + + public boolean isEtagChanged() { + return StringExtensionsKt.eTagChanged(getEtag(), getEtagOnServer()); + } + + public boolean isSharedViaLink() { + return this.sharedViaLink; + } + + public boolean isShared() { + return isSharedViaLink() || isSharedWithSharee() || isSharedWithMe() || !sharees.isEmpty(); + } + + public String getPermissions() { + return this.permissions; + } + + public String getRemoteId() { + return this.remoteId; + } + + public boolean isUpdateThumbnailNeeded() { + return this.updateThumbnailNeeded; + } + + public boolean isDownloading() { + return this.downloading; + } + + public boolean isRootDirectory() { + return ROOT_PATH.equals(decryptedRemotePath); + } + + public boolean isOfflineOperation() { + return getRemoteId() == null; + } + + public String getEtagInConflict() { + return this.etagInConflict; + } + + public boolean isSharedWithSharee() { + return this.sharedWithSharee; + } + + public boolean isFavorite() { + return this.favorite; + } + + public boolean shouldHide() { + return this.hidden; + } + + public boolean isEncrypted() { + return this.encrypted; + } + + public WebdavEntry.MountType getMountType() { + return this.mountType; + } + + public int getUnreadCommentsCount() { + return this.unreadCommentsCount; + } + + public String getOwnerId() { + return this.ownerId; + } + + public String getOwnerDisplayName() { + return this.ownerDisplayName; + } + + public String getNote() { + return this.note; + } + + public List getSharees() { + return this.sharees; + } + + public String getRichWorkspace() { + return this.richWorkspace; + } + + public void setFileId(long fileId) { + this.fileId = fileId; + } + + public void setLocalId(long localId) { + this.localId = localId; + } + + public void setParentId(long parentId) { + this.parentId = parentId; + } + + public void setFileLength(long fileLength) { + this.fileLength = fileLength; + } + + public void setCreationTimestamp(long creationTimestamp) { + this.creationTimestamp = creationTimestamp; + } + + public void setModificationTimestamp(long modificationTimestamp) { + this.modificationTimestamp = modificationTimestamp; + } + + public void setModificationTimestampAtLastSyncForData(long modificationTimestampAtLastSyncForData) { + this.modificationTimestampAtLastSyncForData = modificationTimestampAtLastSyncForData; + } + + public void setRemotePath(String remotePath) { + this.remotePath = remotePath; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public void setLastSyncDateForProperties(long lastSyncDateForProperties) { + this.lastSyncDateForProperties = lastSyncDateForProperties; + } + + public void setLastSyncDateForData(long lastSyncDateForData) { + this.lastSyncDateForData = lastSyncDateForData; + } + + public void setPreviewAvailable(boolean previewAvailable) { + this.previewAvailable = previewAvailable; + } + + public void setSharedViaLink(boolean sharedViaLink) { + this.sharedViaLink = sharedViaLink; + } + + public void setPermissions(String permissions) { + this.permissions = permissions; + } + + public void setRemoteId(String remoteId) { + this.remoteId = remoteId; + } + + public void setUpdateThumbnailNeeded(boolean updateThumbnailNeeded) { + this.updateThumbnailNeeded = updateThumbnailNeeded; + } + + public void setDownloading(boolean downloading) { + this.downloading = downloading; + } + + public void setEtagInConflict(String etagInConflict) { + this.etagInConflict = etagInConflict; + } + + public void setSharedWithSharee(boolean sharedWithSharee) { + this.sharedWithSharee = sharedWithSharee; + } + + public void setFavorite(boolean favorite) { + this.favorite = favorite; + } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + } + + public void setEncrypted(boolean encrypted) { + this.encrypted = encrypted; + } + + public void setMountType(WebdavEntry.MountType mountType) { + this.mountType = mountType; + } + + public void setUnreadCommentsCount(int unreadCommentsCount) { + this.unreadCommentsCount = unreadCommentsCount; + } + + public void setOwnerId(String ownerId) { + this.ownerId = ownerId; + } + + public void setOwnerDisplayName(String ownerDisplayName) { + this.ownerDisplayName = ownerDisplayName; + } + + public void setNote(String note) { + this.note = note; + } + + public void setSharees(List sharees) { + this.sharees = sharees; + } + + public void setRichWorkspace(String richWorkspace) { + this.richWorkspace = richWorkspace; + } + + public long getFirstShareTimestamp() { + return firstShareTimestamp; + } + + public void setFirstShareTimestamp(long firstShareTimestamp) { + this.firstShareTimestamp = firstShareTimestamp; + } + + public boolean isLocked() { + return locked; + } + + public void setLocked(boolean locked) { + this.locked = locked; + } + + @Nullable + public FileLockType getLockType() { + return lockType; + } + + public void setLockType(@Nullable FileLockType lockType) { + this.lockType = lockType; + } + + @Nullable + public String getLockOwnerId() { + return lockOwnerId; + } + + public void setLockOwnerId(@Nullable String lockOwnerId) { + this.lockOwnerId = lockOwnerId; + } + + @Nullable + public String getLockOwnerDisplayName() { + return lockOwnerDisplayName; + } + + public void setLockOwnerDisplayName(@Nullable String lockOwnerDisplayName) { + this.lockOwnerDisplayName = lockOwnerDisplayName; + } + + @Nullable + public String getLockOwnerEditor() { + return lockOwnerEditor; + } + + public void setLockOwnerEditor(@Nullable String lockOwnerEditor) { + this.lockOwnerEditor = lockOwnerEditor; + } + + public long getLockTimestamp() { + return lockTimestamp; + } + + public void setLockTimestamp(long lockTimestamp) { + this.lockTimestamp = lockTimestamp; + } + + public long getLockTimeout() { + return lockTimeout; + } + + public void setLockTimeout(long lockTimeout) { + this.lockTimeout = lockTimeout; + } + + @Nullable + public String getLockToken() { + return lockToken; + } + + public void setLockToken(@Nullable String lockToken) { + this.lockToken = lockToken; + } + + public void setImageDimension(@Nullable ImageDimension imageDimension) { + this.imageDimension = imageDimension; + } + + @Nullable + public ImageDimension getImageDimension() { + return imageDimension; + } + + public void setGeoLocation(@Nullable GeoLocation geolocation) { + this.geolocation = geolocation; + } + + @Nullable + public GeoLocation getGeoLocation() { + return geolocation; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public long getE2eCounter() { + return e2eCounter; + } + + public void setE2eCounter(@Nullable Long e2eCounter) { + this.e2eCounter = Objects.requireNonNullElse(e2eCounter, -1L); + } + + public boolean isInternalFolderSync() { + if (internalFolderSyncTimestamp == null) { + return false; + } + + return internalFolderSyncTimestamp >= 0; + } + + public Long getInternalFolderSyncTimestamp() { + return Objects.requireNonNullElse(internalFolderSyncTimestamp, -1L); + } + + public void setInternalFolderSyncTimestamp(Long internalFolderSyncTimestamp) { + this.internalFolderSyncTimestamp = internalFolderSyncTimestamp; + } + + public String getInternalFolderSyncResult() { + return internalFolderSyncResult; + } + + public void setInternalFolderSyncResult(String internalFolderSyncResult) { + this.internalFolderSyncResult = internalFolderSyncResult; + } + + public boolean isAPKorAAB() { + if (BuildHelper.INSTANCE.isFlavourGPlay()) { + return getFileName().endsWith(".apk") || getFileName().endsWith(".aab"); + } else { + return false; + } + } + + public long getUploadTimestamp() { + return uploadTimestamp; + } + + public void setUploadTimestamp(long uploadTimestamp) { + this.uploadTimestamp = uploadTimestamp; + } + + public boolean exists() { + final String storagePath = getStoragePath(); + return storagePath != null && new File(storagePath).exists(); + } + + public void setReason(String value) { + reason = value; + } + + public String getReason() { + return reason; + } + + public void setIsRecommendedFile(boolean value) { + recommendedFile = value; + } + + public boolean isRecommendedFile() { + return recommendedFile; + } + + // only root directories parent id can be 0 + public boolean hasValidParentId() { + if (isRootDirectory()) { + return getParentId() == 0; + } else { + return getParentId() != 0; + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFileDepth.kt b/app/src/main/java/com/owncloud/android/datamodel/OCFileDepth.kt new file mode 100644 index 000000000000..3e36f901543b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFileDepth.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel + +enum class OCFileDepth { + Root, + FirstLevel, + DeepLevel +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFileListAdapterDataProviderImpl.kt b/app/src/main/java/com/owncloud/android/datamodel/OCFileListAdapterDataProviderImpl.kt new file mode 100644 index 000000000000..3f09d4d6e276 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFileListAdapterDataProviderImpl.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel + +import com.nextcloud.client.database.entity.FileEntity +import com.owncloud.android.ui.adapter.helper.OCFileListAdapterDataProvider + +@Suppress("ReturnCount") +class OCFileListAdapterDataProviderImpl(private val storageManager: FileDataStorageManager) : + OCFileListAdapterDataProvider { + override fun convertToOCFiles(id: Long): List = + storageManager.offlineOperationsRepository.convertToOCFiles(id) + + override suspend fun getFolderContent(id: Long): List = + storageManager.fileDao.getFolderContentSuspended(id) + + override fun createFileInstance(entity: FileEntity): OCFile = storageManager.createFileInstance(entity) +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/PushConfigurationState.java b/app/src/main/java/com/owncloud/android/datamodel/PushConfigurationState.java new file mode 100644 index 000000000000..617f703232cb --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/PushConfigurationState.java @@ -0,0 +1,72 @@ +/* + * Nextcloud Android client application + * + * @author Mario Danic + * @author Andy Scherzinger + * Copyright (C) 2017 Mario Danic + * Copyright (C) 2018 Andy Scherzinger + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.datamodel; + +public class PushConfigurationState { + public String pushToken; + public String deviceIdentifier; + public String deviceIdentifierSignature; + public String userPublicKey; + public boolean shouldBeDeleted; + + public PushConfigurationState(String pushToken, String deviceIdentifier, String deviceIdentifierSignature, String userPublicKey, boolean shouldBeDeleted) { + this.pushToken = pushToken; + this.deviceIdentifier = deviceIdentifier; + this.deviceIdentifierSignature = deviceIdentifierSignature; + this.userPublicKey = userPublicKey; + this.shouldBeDeleted = shouldBeDeleted; + } + + public PushConfigurationState() { + // empty constructor for JSON parser + } + + public String getPushToken() { + return this.pushToken; + } + + public String getDeviceIdentifier() { + return this.deviceIdentifier; + } + + public String getDeviceIdentifierSignature() { + return this.deviceIdentifierSignature; + } + + public String getUserPublicKey() { + return this.userPublicKey; + } + + public boolean isShouldBeDeleted() { + return this.shouldBeDeleted; + } + + public void setPushToken(String pushToken) { + this.pushToken = pushToken; + } + + public void setDeviceIdentifier(String deviceIdentifier) { + this.deviceIdentifier = deviceIdentifier; + } + + public void setDeviceIdentifierSignature(String deviceIdentifierSignature) { + this.deviceIdentifierSignature = deviceIdentifierSignature; + } + + public void setUserPublicKey(String userPublicKey) { + this.userPublicKey = userPublicKey; + } + + public void setShouldBeDeleted(boolean shouldBeDeleted) { + this.shouldBeDeleted = shouldBeDeleted; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ReceiverFlag.kt b/app/src/main/java/com/owncloud/android/datamodel/ReceiverFlag.kt new file mode 100644 index 000000000000..b64be497f94e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/ReceiverFlag.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi + +enum class ReceiverFlag { + NotExported; + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun getId(): Int = Context.RECEIVER_NOT_EXPORTED +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/SharesType.kt b/app/src/main/java/com/owncloud/android/datamodel/SharesType.kt new file mode 100644 index 000000000000..fb58a83bdbb9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/SharesType.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel + +enum class SharesType { + INTERNAL, + EXTERNAL +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/SignatureVerification.kt b/app/src/main/java/com/owncloud/android/datamodel/SignatureVerification.kt new file mode 100644 index 000000000000..a021746311a6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/SignatureVerification.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Unpublished + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.accounts.Account +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SignatureVerification(val signatureValid: Boolean, val account: Account?) : Parcelable diff --git a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java new file mode 100644 index 000000000000..ef7d005f9e82 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java @@ -0,0 +1,343 @@ +/* + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2016 Tobias Kaminsky + * Copyright (C) 2016 Nextcloud + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.datamodel; + +import com.nextcloud.client.device.PowerManagementService; +import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.preferences.SubFolderRule; +import com.nextcloud.utils.extensions.SyncedFolderExtensionsKt; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.utils.MimeTypeUtil; + +import java.io.File; +import java.io.Serializable; + +/** + * Synced folder entity containing all information per synced folder. + */ +public class SyncedFolder implements Serializable, Cloneable { + public static final long UNPERSISTED_ID = Long.MIN_VALUE; + public static final long EMPTY_ENABLED_TIMESTAMP_MS = -1; + public static final long NOT_SCANNED_YET = -1; + private static final long serialVersionUID = -793476118299906429L; + + + + private long id; + private String localPath; + private String remotePath; + private boolean wifiOnly; + private boolean chargingOnly; + private boolean existing; + private boolean subfolderByDate; + private String account; + private int uploadAction; + private int nameCollisionPolicy; + private boolean enabled; + private long enabledTimestampMs; + private MediaFolderType type; + private boolean hidden; + private SubFolderRule subfolderRule; + private boolean excludeHidden; + private long lastScanTimestampMs; + + /** + * constructor for new, to be persisted entity. + * + * @param localPath local path + * @param remotePath remote path + * @param wifiOnly upload on wifi only flag + * @param chargingOnly upload on charging only + * @param existing upload existing files + * @param subfolderByDate create sub-folders by date (month) + * @param account the account owning the synced folder + * @param uploadAction the action to be done after the upload + * @param nameCollisionPolicy the behaviour to follow if detecting a collision + * @param enabled flag if synced folder config is active + * @param timestampMs the current timestamp in milliseconds + * @param type the type of the folder + * @param hidden hide item flag + * @param subFolderRule whether to filter subFolder by year/month/day + * @param excludeHidden exclude hidden file or folder, for {@link MediaFolderType#CUSTOM} only + */ + public SyncedFolder(String localPath, + String remotePath, + boolean wifiOnly, + boolean chargingOnly, + boolean existing, + boolean subfolderByDate, + String account, + int uploadAction, + int nameCollisionPolicy, + boolean enabled, + long timestampMs, + MediaFolderType type, + boolean hidden, + SubFolderRule subFolderRule, + boolean excludeHidden, + long lastScanTimestampMs) { + this(UNPERSISTED_ID, + localPath, + remotePath, + wifiOnly, + chargingOnly, + existing, + subfolderByDate, + account, + uploadAction, + nameCollisionPolicy, + enabled, + timestampMs, + type, + hidden, + subFolderRule, + excludeHidden, + lastScanTimestampMs); + } + + /** + * constructor for wrapping existing folders. + * + * @param id id + */ + public SyncedFolder(long id, + String localPath, + String remotePath, + boolean wifiOnly, + boolean chargingOnly, + boolean existing, + boolean subfolderByDate, + String account, + int uploadAction, + int nameCollisionPolicy, + boolean enabled, + long timestampMs, + MediaFolderType type, + boolean hidden, + SubFolderRule subFolderRule, + boolean excludeHidden, + long lastScanTimestampMs) { + this.id = id; + this.localPath = localPath; + this.remotePath = remotePath; + this.wifiOnly = wifiOnly; + this.chargingOnly = chargingOnly; + this.existing = existing; + this.subfolderByDate = subfolderByDate; + this.account = account; + this.uploadAction = uploadAction; + this.nameCollisionPolicy = nameCollisionPolicy; + this.setEnabled(enabled, timestampMs); + this.type = type; + this.hidden = hidden; + this.subfolderRule = subFolderRule; + this.excludeHidden = excludeHidden; + this.lastScanTimestampMs = lastScanTimestampMs; + } + + /** + * @param timestampMs the current timestamp in milliseconds + */ + public void setEnabled(boolean enabled, long timestampMs) { + this.enabled = enabled; + this.enabledTimestampMs = enabled ? timestampMs : EMPTY_ENABLED_TIMESTAMP_MS; + } + + public Object clone() { + try { + return super.clone(); + } catch (CloneNotSupportedException e) { + return null; + } + } + + public long getId() { + return this.id; + } + + public String getLocalPath() { + return this.localPath; + } + + public String getRemotePath() { + return this.remotePath; + } + + public boolean isWifiOnly() { + return this.wifiOnly; + } + + public boolean isChargingOnly() { + return this.chargingOnly; + } + + /** + * Indicates whether the "Also upload existing files" option is enabled for this folder. + * + *

+ * This flag controls how files in the folder are treated when auto-upload is enabled: + *

    + *
  • If {@code true} (existing files are included): + *
      + *
    • All files in the folder, regardless of creation date, will be uploaded.
    • + *
    + *
  • + *
  • If {@code false} (existing files are skipped): + *
      + *
    • Only files created or added after the folder was enabled will be uploaded.
    • + *
    • Files that existed before enabling will be skipped, based on their creation time.
    • + *
    + *
  • + *
+ *

+ * + * @return {@code true} if existing files should also be uploaded, {@code false} otherwise + */ + public boolean isExisting() { + return this.existing; + } + + public boolean isSubfolderByDate() { + return this.subfolderByDate; + } + + public String getAccount() { + return this.account; + } + + public int getUploadAction() { + return this.uploadAction; + } + + public int getNameCollisionPolicyInt() { + return this.nameCollisionPolicy; + } + + public NameCollisionPolicy getNameCollisionPolicy() { + return NameCollisionPolicy.deserialize(nameCollisionPolicy); + } + + public boolean isEnabled() { + return this.enabled; + } + + public long getEnabledTimestampMs() { + return this.enabledTimestampMs; + } + + public MediaFolderType getType() { + return this.type; + } + + public boolean isHidden() { + return this.hidden; + } + + public SubFolderRule getSubfolderRule() { return this.subfolderRule; } + + public void setId(long id) { + this.id = id; + } + + public void setLocalPath(String localPath) { + this.localPath = localPath; + } + + public void setRemotePath(String remotePath) { + this.remotePath = remotePath; + } + + public void setWifiOnly(boolean wifiOnly) { + this.wifiOnly = wifiOnly; + } + + public void setChargingOnly(boolean chargingOnly) { + this.chargingOnly = chargingOnly; + } + + public void setExisting(boolean existing) { + this.existing = existing; + } + + public void setSubfolderByDate(boolean subfolderByDate) { + this.subfolderByDate = subfolderByDate; + } + + public void setAccount(String account) { + this.account = account; + } + + public void setUploadAction(int uploadAction) { + this.uploadAction = uploadAction; + } + + public void setNameCollisionPolicy(int nameCollisionPolicy) { + this.nameCollisionPolicy = nameCollisionPolicy; + } + + public void setType(MediaFolderType type) { + this.type = type; + } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + } + + public void setSubFolderRule(SubFolderRule subFolderRule) { this.subfolderRule = subFolderRule; } + + public boolean isExcludeHidden() { + return excludeHidden; + } + + public void setExcludeHidden(boolean excludeHidden) { + this.excludeHidden = excludeHidden; + } + + /** + * Determines whether the given file: + *
    + *
  • Exists under the configured {@code localPath}
  • + *
  • Matches the expected media type of this folder
  • + *
+ * + * @param file the file to validate + * @param filePath the absolute path of the file e.g. /storage/emulated/0/DCIM/Camera/document.pdf + * @return {@code true} if the file is located under {@code localPath} + * and matches the folder's media type; {@code false} otherwise + */ + public boolean isFileInFolderWithCorrectMediaType(File file, String filePath) { + if (filePath == null || filePath.isEmpty() || localPath == null || localPath.isEmpty()) { + return false; + } + + String normalizedLocal = localPath.endsWith(File.separator) + ? localPath + : localPath + File.separator; + + boolean isInLocalDirectory = filePath.startsWith(normalizedLocal); + + boolean isCorrectMediaType = + (getType() == MediaFolderType.IMAGE && MimeTypeUtil.isImage(file)) || + (getType() == MediaFolderType.VIDEO && MimeTypeUtil.isVideo(file)) || + getType() == MediaFolderType.CUSTOM; + + return isInLocalDirectory && isCorrectMediaType; + } + + public long getLastScanTimestampMs() { return lastScanTimestampMs; } + + public void setLastScanTimestampMs(long lastScanTimestampMs) { this.lastScanTimestampMs = lastScanTimestampMs; } + + public long getTotalScanInterval(ConnectivityService connectivityService, PowerManagementService powerManagementService) { + final var calculatedScanInterval = SyncedFolderExtensionsKt.calculateScanInterval(this, connectivityService, powerManagementService); + return lastScanTimestampMs + calculatedScanInterval.getFirst(); + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderDisplayItem.java b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderDisplayItem.java new file mode 100644 index 000000000000..cbbf4453aadb --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderDisplayItem.java @@ -0,0 +1,148 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 Nextcloud + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel; + +import com.nextcloud.client.preferences.SubFolderRule; + +import java.util.List; + +/** + * Display item specialization for synced folder objects to be displayed in a list/grid view adding further + * information to be displayed in the UI but not part of the persisted underlying {@link SyncedFolder} object. + */ +public class SyncedFolderDisplayItem extends SyncedFolder { + private List filePaths; + private String folderName; + private long numberOfFiles; + + + /** + * constructor for the display item specialization for a synced folder object. + * + * @param id id + * @param localPath local path + * @param remotePath remote path + * @param wifiOnly upload on wifi only flag + * @param chargingOnly upload on charging only + * @param existing also upload existing + * @param subfolderByDate create sub-folders by date (month) + * @param account the account owning the synced folder + * @param uploadAction the action to be done after the upload + * @param enabled flag if synced folder config is active + * @param filePaths the UI info for the file path + * @param folderName the UI info for the folder's name + * @param numberOfFiles the UI info for number of files within the folder + * @param type the type of the folder + * @param hidden hide item flag + * @param subFolderRule whether to filter subFolder by year/month/day + * @param excludeHidden exclude hidden file or folder, for {@link MediaFolderType#CUSTOM} only + */ + public SyncedFolderDisplayItem(long id, + String localPath, + String remotePath, + boolean wifiOnly, + boolean chargingOnly, + boolean existing, + boolean subfolderByDate, + String account, + int uploadAction, + int nameCollisionPolicy, + boolean enabled, + long timestampMs, + List filePaths, + String folderName, + long numberOfFiles, + MediaFolderType type, + boolean hidden, + SubFolderRule subFolderRule, + boolean excludeHidden, + long lastScanTimestampMs) { + super(id, + localPath, + remotePath, + wifiOnly, + chargingOnly, + existing, + subfolderByDate, + account, + uploadAction, + nameCollisionPolicy, + enabled, + timestampMs, + type, + hidden, + subFolderRule, + excludeHidden, + lastScanTimestampMs); + this.filePaths = filePaths; + this.folderName = folderName; + this.numberOfFiles = numberOfFiles; + } + + public SyncedFolderDisplayItem(long id, + String localPath, + String remotePath, + boolean wifiOnly, + boolean chargingOnly, + boolean existing, + boolean subfolderByDate, + String account, + int uploadAction, + int nameCollisionPolicy, + boolean enabled, + long timestampMs, + String folderName, + MediaFolderType type, + boolean hidden, + SubFolderRule subFolderRule, + boolean excludeHidden, + long lastScanTimestampMs) { + super(id, + localPath, + remotePath, + wifiOnly, + chargingOnly, + existing, + subfolderByDate, + account, + uploadAction, + nameCollisionPolicy, + enabled, + timestampMs, + type, + hidden, + subFolderRule, + excludeHidden, + lastScanTimestampMs); + this.folderName = folderName; + } + + public List getFilePaths() { + return this.filePaths; + } + + public String getFolderName() { + return this.folderName; + } + + public long getNumberOfFiles() { + return this.numberOfFiles; + } + + public void setFilePaths(List filePaths) { + this.filePaths = filePaths; + } + + public void setFolderName(String folderName) { + this.folderName = folderName; + } + + public void setNumberOfFiles(long numberOfFiles) { + this.numberOfFiles = numberOfFiles; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderObserver.kt b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderObserver.kt new file mode 100644 index 000000000000..fbb4779fec0f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderObserver.kt @@ -0,0 +1,54 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel + +import com.nextcloud.client.account.User +import com.nextcloud.client.database.dao.SyncedFolderDao +import com.owncloud.android.lib.resources.files.model.ServerFileInterface +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +object SyncedFolderObserver { + + @Volatile + private var syncedFoldersMap = mapOf>() + + private var job: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + fun start(dao: SyncedFolderDao) { + if (job?.isActive == true) return + + job = scope.launch { + dao.getAllAsFlow() + .distinctUntilChanged() + .collect { updatedEntities -> + syncedFoldersMap = updatedEntities + .filter { it.remotePath != null && it.account != null } + .groupBy { it.account!! } + .mapValues { (_, entities) -> + entities.map { it.remotePath!!.trimEnd('/') }.toSet() + } + } + } + } + + @Suppress("ReturnCount") + fun isAutoUploadFolder(file: ServerFileInterface, user: User): Boolean { + val accountFolders = syncedFoldersMap[user.accountName] ?: return false + val normalizedRemotePath = file.remotePath.trimEnd('/') + if (normalizedRemotePath.isEmpty()) return false + return accountFolders.any { entityPath -> + normalizedRemotePath == entityPath + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.kt b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.kt new file mode 100644 index 000000000000..1dae23982037 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.kt @@ -0,0 +1,230 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import com.nextcloud.client.account.User +import com.nextcloud.client.core.Clock +import com.nextcloud.client.database.NextcloudDatabase +import com.nextcloud.client.database.dao.SyncedFolderDao +import com.nextcloud.client.database.entity.toSyncedFolder +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.preferences.AppPreferencesImpl +import com.nextcloud.client.preferences.SubFolderRule +import com.owncloud.android.datamodel.MediaFolderType.Companion.getById +import com.owncloud.android.datamodel.SyncedFolderObserver.start +import com.owncloud.android.db.ProviderMeta +import com.owncloud.android.lib.common.utils.Log_OC +import java.io.File +import java.util.Observable + +class SyncedFolderProvider( + contentResolver: ContentResolver, + @JvmField val preferences: AppPreferences, + private val clock: Clock +) : Observable() { + + companion object { + private val TAG: String = SyncedFolderProvider::class.java.simpleName + } + + private val resolver: ContentResolver = contentResolver + private val dao: SyncedFolderDao = NextcloudDatabase.instance().syncedFolderDao() + + init { + start(dao) + } + + fun storeSyncedFolder(syncedFolder: SyncedFolder): Long { + Log_OC.v(TAG, "Inserting ${syncedFolder.localPath} with enabled=${syncedFolder.isEnabled}") + return resolver.insert( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + createContentValuesFromSyncedFolder(syncedFolder) + ) + ?.pathSegments?.get(1)?.toLong() + ?: run { + Log_OC.e(TAG, "Failed to insert item ${syncedFolder.localPath} into folder sync db.") + -1L + } + } + + fun countEnabledSyncedFolders(): Int = resolver.query( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + null, + "${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ENABLED} = ?", + arrayOf("1"), + null + )?.use { it.count } ?: 0 + + val syncedFolders: MutableList + get() = resolver.query( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + null, + null, + null, + null + )?.use { cursor -> + ArrayList(cursor.count).also { + while (cursor.moveToNext()) it.add(createSyncedFolderFromCursor(cursor)) + } + } ?: run { + Log_OC.e(TAG, "DB error creating read all cursor for synced folders.") + ArrayList(0) + } + + fun updateSyncedFolderEnabled(id: Long, enabled: Boolean): Int { + Log_OC.v(TAG, "Storing synced folder id$id with enabled=$enabled") + return resolver.query( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + null, + "${ProviderMeta.ProviderTableMeta._ID}=?", + arrayOf(id.toString()), + null + )?.use { cursor -> + if (cursor.count == 1 && cursor.moveToNext()) { + val syncedFolder = createSyncedFolderFromCursor(cursor) + syncedFolder.setEnabled(enabled, clock.currentTime) + updateSyncFolder(syncedFolder) + } else { + Log_OC.e( + TAG, + "${cursor.count} items for id=$id available in sync folder database. " + + "Expected 1. Failed to update sync folder db." + ) + 0 + } + } ?: run { + Log_OC.e(TAG, "Sync folder db cursor for ID=$id in NULL.") + 0 + } + } + + fun findByLocalPathAndAccount(localPath: String, user: User): SyncedFolder? = + dao.findByLocalPathAndAccount(localPath, user.accountName)?.toSyncedFolder() + + fun getSyncedFolderByID(syncedFolderID: Long): SyncedFolder? = resolver.query( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + null, + "${ProviderMeta.ProviderTableMeta._ID} =?", + arrayOf(syncedFolderID.toString()), + null + )?.use { cursor -> + if (cursor.count == 1 && cursor.moveToFirst()) createSyncedFolderFromCursor(cursor) else null + } + + fun deleteSyncFoldersForAccount(user: User): Int = resolver.delete( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + "${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ACCOUNT} = ?", + arrayOf(user.accountName) + ) + + private fun deleteSyncFolderWithId(id: Long) { + resolver.delete( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + "${ProviderMeta.ProviderTableMeta._ID} = ?", + arrayOf(id.toString()) + ) + } + + fun updateAutoUploadPaths(context: Context?) { + for (syncedFolder in syncedFolders) { + if (!File(syncedFolder.localPath).exists()) { + var localPath = syncedFolder.localPath + if (localPath.endsWith(OCFile.PATH_SEPARATOR)) { + localPath = localPath.substring(0, localPath.lastIndexOf('/')) + } + localPath = localPath.substring(0, localPath.lastIndexOf('/')) + + if (File(localPath).exists()) { + syncedFolder.localPath = localPath + updateSyncFolder(syncedFolder) + } else { + deleteSyncFolderWithId(syncedFolder.id) + } + } + } + + context?.let { AppPreferencesImpl.fromContext(it).setAutoUploadPathsUpdateEnabled(true) } + } + + fun deleteSyncedFoldersNotInList(ids: MutableList?): Int = resolver.delete( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + "${ProviderMeta.ProviderTableMeta._ID} NOT IN (?)", + arrayOf(ids.toString()) + ).also { if (it > 0) preferences.setLegacyClean(true) } + + fun deleteSyncedFolder(id: Long): Int = resolver.delete( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + "${ProviderMeta.ProviderTableMeta._ID} = ?", + arrayOf(id.toString()) + ) + + fun updateSyncFolder(syncedFolder: SyncedFolder): Int { + Log_OC.v(TAG, "Updating ${syncedFolder.localPath} with enabled=${syncedFolder.isEnabled}") + return resolver.update( + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + createContentValuesFromSyncedFolder(syncedFolder), + "${ProviderMeta.ProviderTableMeta._ID}=?", + arrayOf(syncedFolder.id.toString()) + ) + } + + private fun createSyncedFolderFromCursor(cursor: Cursor): SyncedFolder { + fun str(col: String) = cursor.getString(cursor.getColumnIndexOrThrow(col)) + fun int(col: String) = cursor.getInt(cursor.getColumnIndexOrThrow(col)) + fun long(col: String) = cursor.getLong(cursor.getColumnIndexOrThrow(col)) + fun bool(col: String) = int(col) == 1 + + return SyncedFolder( + long(ProviderMeta.ProviderTableMeta._ID), + str(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_LOCAL_PATH), + str(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_REMOTE_PATH), + bool(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_WIFI_ONLY), + bool(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_CHARGING_ONLY), + bool(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_EXISTING), + bool(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_SUBFOLDER_BY_DATE), + str(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ACCOUNT), + int(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_UPLOAD_ACTION), + int(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_NAME_COLLISION_POLICY), + bool(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ENABLED), + long(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ENABLED_TIMESTAMP_MS), + getById(int(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_TYPE)), + bool(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_HIDDEN), + SubFolderRule.entries[int(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_SUBFOLDER_RULE)], + bool(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_EXCLUDE_HIDDEN), + long(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_LAST_SCAN_TIMESTAMP_MS) + ) + } + + private fun createContentValuesFromSyncedFolder(syncedFolder: SyncedFolder): ContentValues = ContentValues().apply { + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_LOCAL_PATH, syncedFolder.localPath) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_REMOTE_PATH, syncedFolder.remotePath) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_WIFI_ONLY, syncedFolder.isWifiOnly) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_CHARGING_ONLY, syncedFolder.isChargingOnly) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_EXISTING, syncedFolder.isExisting) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ENABLED, syncedFolder.isEnabled) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ENABLED_TIMESTAMP_MS, syncedFolder.enabledTimestampMs) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_SUBFOLDER_BY_DATE, syncedFolder.isSubfolderByDate) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ACCOUNT, syncedFolder.account) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_UPLOAD_ACTION, syncedFolder.uploadAction) + put( + ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_NAME_COLLISION_POLICY, + syncedFolder.nameCollisionPolicyInt + ) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_TYPE, syncedFolder.type.id) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_HIDDEN, syncedFolder.isHidden) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_SUBFOLDER_RULE, syncedFolder.subfolderRule.ordinal) + put(ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_EXCLUDE_HIDDEN, syncedFolder.isExcludeHidden) + put( + ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_LAST_SCAN_TIMESTAMP_MS, + syncedFolder.lastScanTimestampMs + ) + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/Template.kt b/app/src/main/java/com/owncloud/android/datamodel/Template.kt new file mode 100644 index 000000000000..3f3beb6dea53 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/Template.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Template for creating a file from it via RichDocuments app + */ +@Parcelize +data class Template(val id: Long, val name: String, val thumbnailLink: String, val type: Type, val extension: String) : + Parcelable { + enum class Type { + DOCUMENT, + SPREADSHEET, + PRESENTATION, + UNKNOWN; + + companion object { + @JvmStatic + fun parse(name: String) = try { + valueOf(name.uppercase()) + } catch (_: IllegalArgumentException) { + UNKNOWN + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java new file mode 100644 index 000000000000..3586f6adeba5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -0,0 +1,1331 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022-2023 Álvaro Brey + * SPDX-FileCopyrightText: 2017-2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016-2020 Andy Scherzinger + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.datamodel; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.MediaMetadataRetriever; +import android.media.ThumbnailUtils; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.view.Display; +import android.view.View; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.utils.BitmapExtensionsKt; +import com.nextcloud.utils.extensions.OCFileExtensionsKt; +import com.nextcloud.utils.extensions.OwnCloudClientExtensionsKt; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.model.ImageDimension; +import com.owncloud.android.lib.resources.files.model.ServerFileInterface; +import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile; +import com.owncloud.android.ui.TextDrawable; +import com.owncloud.android.ui.adapter.DiskLruImageCache; +import com.owncloud.android.ui.fragment.FileFragment; +import com.owncloud.android.ui.preview.PreviewImageFragment; +import com.owncloud.android.utils.BitmapUtils; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.httpclient.methods.GetMethod; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.content.ContextCompat; +import androidx.core.content.res.ResourcesCompat; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import static com.nextcloud.utils.extensions.ThumbnailsCacheManagerExtensionsKt.getExifOrientation; + +/** + * Manager for concurrent access to thumbnails cache. + */ +public final class ThumbnailsCacheManager { + private static final int READ_TIMEOUT = 40000; + private static final int CONNECTION_TIMEOUT = 5000; + + public static final String PREFIX_RESIZED_IMAGE = "r"; + public static final String PREFIX_THUMBNAIL = "t"; + + private static final String TAG = ThumbnailsCacheManager.class.getSimpleName(); + private static final String PNG_MIMETYPE = "image/png"; + private static final String CACHE_FOLDER = "thumbnailCache"; + public static final String AVATAR = "avatar"; + private static final String AVATAR_TIMESTAMP = "avatarTimestamp"; + private static final String ETAG = "ETag"; + + private static final Object mThumbnailsDiskCacheLock = new Object(); + private static volatile DiskLruImageCache mThumbnailCache; + private static volatile boolean mThumbnailCacheStarting = true; + + private static final int DISK_CACHE_SIZE = 1024 * 1024 * 200; // 200MB + private static final CompressFormat mCompressFormat = CompressFormat.JPEG; + private static final int mCompressQuality = 70; + private static OwnCloudClient mClient; + public static final int THUMBNAIL_SIZE_IN_KB = 512; + private static final int RESIZED_IMAGE_SIZE_IN_KB = 10240; + + public static final Bitmap mDefaultImg = BitmapFactory.decodeResource(MainApp.getAppContext().getResources(), + R.drawable.file_image); + + public static final Bitmap mDefaultVideo = BitmapFactory.decodeResource(MainApp.getAppContext().getResources(), + R.drawable.file_movie); + + private ThumbnailsCacheManager() { + } + + private static final ExecutorService executor = Executors.newSingleThreadExecutor(); + + public static void initDiskCacheAsync() { + executor.execute(() -> { + synchronized (mThumbnailsDiskCacheLock) { + mThumbnailCacheStarting = true; + + if (mThumbnailCache == null) { + try { + File cacheDir = MainApp.getAppContext().getCacheDir(); + + if (cacheDir == null) { + throw new FileNotFoundException("Thumbnail cache could not be opened"); + } + + String cachePath = cacheDir.getPath() + File.separator + CACHE_FOLDER; + Log_OC.d(TAG, "thumbnail cache dir: " + cachePath); + File diskCacheDir = new File(cachePath); + + // migrate from external cache to internal cache + File oldCacheDir = MainApp.getAppContext().getExternalCacheDir(); + + if (oldCacheDir != null && oldCacheDir.exists()) { + String cacheOldPath = oldCacheDir.getPath() + File.separator + CACHE_FOLDER; + File diskOldCacheDir = new File(cacheOldPath); + + FileStorageUtils.copyDirs(diskOldCacheDir, diskCacheDir); + FileStorageUtils.deleteRecursive(diskOldCacheDir); + } + + mThumbnailCache = new DiskLruImageCache(diskCacheDir, DISK_CACHE_SIZE, mCompressFormat, + mCompressQuality); + } catch (Exception e) { + Log_OC.d(TAG, "Disk cache init failed", e); + mThumbnailCache = null; + } + } + mThumbnailCacheStarting = false; // Finished initialization + mThumbnailsDiskCacheLock.notifyAll(); // Wake any waiting threads + } + }); + } + + /** + * Converts size of file icon from dp to pixel + * @return int + */ + public static int getThumbnailDimension() { + // Converts dp to pixel + Resources r = MainApp.getAppContext().getResources(); + return Math.round(r.getDimension(R.dimen.file_icon_size_grid)); + } + + /** + * Converts dimension of screen as point + * + * @return Point + */ + private static Point getScreenDimension() { + WindowManager wm = (WindowManager) MainApp.getAppContext().getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Point point = new Point(); + display.getSize(point); + return point; + } + + /** + * Add thumbnail to cache + * + * @param imageKey: thumb key + * @param bitmap: image for extracting thumbnail + * @param path: image path + * @param pxW: thumbnail width in pixel + * @param pxH: thumbnail height in pixel + * @return Bitmap + */ + public static Bitmap addThumbnailToCache(String imageKey, Bitmap bitmap, String path, int pxW, int pxH) { + + Bitmap thumbnail = ThumbnailUtils.extractThumbnail(bitmap, pxW, pxH); + + // Rotate image, obeying exif tag + int orientation = getExifOrientation(path); + thumbnail = BitmapExtensionsKt.rotateBitmapViaExif(thumbnail, orientation); + + // Add thumbnail to cache + // do not overwrite any pre-existing image + if (!mThumbnailCache.containsKey(imageKey)) { + addBitmapToCache(imageKey, thumbnail); + } + + return thumbnail; + } + + public static void removeFromCache(@Nullable OCFile file) { + if (file == null) { + return; + } + + final var keys = new String[] { PREFIX_RESIZED_IMAGE + file.getRemoteId(), PREFIX_THUMBNAIL + file.getRemoteId() }; + + synchronized (mThumbnailsDiskCacheLock) { + if (mThumbnailCache == null) { + return; + } + + for (String key: keys) { + mThumbnailCache.removeKey(key); + } + } + } + + public static void addBitmapToCache(String key, Bitmap bitmap) { + synchronized (mThumbnailsDiskCacheLock) { + if (mThumbnailCache == null) { + return; + } + + // Check if the bitmap is already cached + Bitmap cachedBitmap = mThumbnailCache.getBitmap(key); + if (cachedBitmap == null) { + cachedBitmap = mThumbnailCache.getScaledBitmap(key, bitmap.getWidth(), bitmap.getHeight()); + } + + if (cachedBitmap != null && BitmapExtensionsKt.allocationKilobyte(cachedBitmap) <= THUMBNAIL_SIZE_IN_KB) { + Log_OC.d(TAG, "Cached version is already within size limits, no need to scale: " + key); + return; + } + + // do not scale down resized images + int size; + if (key.startsWith("r")) { + size = RESIZED_IMAGE_SIZE_IN_KB; + } else { + size = THUMBNAIL_SIZE_IN_KB; + } + + if (BitmapExtensionsKt.allocationKilobyte(bitmap) > size) { + Log_OC.d(TAG, "Scaling bitmap before caching: " + key); + bitmap = BitmapExtensionsKt.scaleUntil(bitmap, size); + } + + mThumbnailCache.put(key, bitmap); + } + } + + public static boolean containsBitmap(String key) { + return mThumbnailCache.containsKey(key); + } + + public static Bitmap getScaledBitmapFromDiskCache(String key, int width, int height) { + synchronized (mThumbnailsDiskCacheLock) { + // Wait while disk cache is started from background thread + while (mThumbnailCacheStarting) { + try { + mThumbnailsDiskCacheLock.wait(); + } catch (InterruptedException e) { + Log_OC.e(TAG, "Wait in mThumbnailsDiskCacheLock was interrupted", e); + } + } + if (mThumbnailCache != null) { + return mThumbnailCache.getScaledBitmap(key, width, height); + } + } + return null; + } + + public static Bitmap getBitmapFromDiskCache(String key) { + synchronized (mThumbnailsDiskCacheLock) { + // Wait while disk cache is started from background thread + while (mThumbnailCacheStarting) { + try { + mThumbnailsDiskCacheLock.wait(); + } catch (InterruptedException e) { + Log_OC.e(TAG, "Wait in mThumbnailsDiskCacheLock was interrupted", e); + } + } + if (mThumbnailCache != null) { + return mThumbnailCache.getBitmap(key); + } + } + return null; + } + + public static Bitmap getScaledThumbnailAfterSave(Bitmap thumbnail, String imageKey) { + Bitmap result = BitmapExtensionsKt.scaleUntil(thumbnail, THUMBNAIL_SIZE_IN_KB); + + synchronized (mThumbnailsDiskCacheLock) { + if (mThumbnailCache != null) { + Log_OC.d(TAG, "Scaling bitmap before caching: " + imageKey); + mThumbnailCache.put(imageKey, result); + } + } + + return result; + } + + public static class ResizedImageGenerationTask extends AsyncTask { + private final FileFragment fileFragment; + private final FileDataStorageManager storageManager; + private final User user; + private final WeakReference imageViewReference; + private final WeakReference frameLayoutReference; + private OCFile file; + private final ConnectivityService connectivityService; + private final int backgroundColor; + + + public ResizedImageGenerationTask(FileFragment fileFragment, + ImageView imageView, + FrameLayout emptyListProgress, + FileDataStorageManager storageManager, + ConnectivityService connectivityService, + User user, + int backgroundColor) throws IllegalArgumentException { + this.fileFragment = fileFragment; + imageViewReference = new WeakReference<>(imageView); + frameLayoutReference = new WeakReference<>(emptyListProgress); + this.storageManager = storageManager; + this.connectivityService = connectivityService; + this.user = user; + this.backgroundColor = backgroundColor; + } + + @Override + protected Bitmap doInBackground(Object... params) { + Bitmap thumbnail = null; + + file = (OCFile) params[0]; + + try { + mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(user.toOwnCloudAccount(), + MainApp.getAppContext()); + + thumbnail = doResizedImageInBackground(file, storageManager); + + if (MimeTypeUtil.isVideo(file) && thumbnail != null) { + thumbnail = addVideoOverlay(thumbnail, MainApp.getAppContext()); + } + + } catch (OutOfMemoryError oome) { + Log_OC.e(TAG, "Out of memory"); + } catch (Throwable t) { + // the app should never break due to a problem with thumbnails + Log_OC.e(TAG, "Generation of thumbnail for " + file + " failed", t); + } + + return thumbnail; + } + + protected void onPostExecute(Bitmap bitmap) { + if (imageViewReference.get() != null) { + final ImageView imageView = imageViewReference.get(); + final FrameLayout frameLayout = frameLayoutReference.get(); + + if (bitmap != null) { + final ResizedImageGenerationTask bitmapWorkerTask = getResizedImageGenerationWorkerTask(imageView); + + if (this == bitmapWorkerTask) { + String tagId = String.valueOf(file.getFileId()); + + if (String.valueOf(imageView.getTag()).equals(tagId)) { + imageView.setVisibility(View.VISIBLE); + imageView.setImageBitmap(bitmap); + imageView.setBackgroundColor(backgroundColor); + + if (frameLayout != null) { + frameLayout.setVisibility(View.GONE); + } + } + } + } else { + new Thread(() -> { + if (connectivityService.isInternetWalled()) { + if (fileFragment instanceof PreviewImageFragment) { + ((PreviewImageFragment) fileFragment).setNoConnectionErrorMessage(); + } + } else { + if (fileFragment instanceof PreviewImageFragment) { + ((PreviewImageFragment) fileFragment).handleUnsupportedImage(); + } + } + }).start(); + + } + } + } + } + + public static class ThumbnailGenerationTaskObject { + private final Object file; + private final String imageKey; + + public ThumbnailGenerationTaskObject(Object file, @Nullable String imageKey) { + this.file = file; + this.imageKey = imageKey; + } + + private Object getFile() { + return file; + } + + private String getImageKey() { + return imageKey; + } + } + + public static class ThumbnailGenerationTask extends AsyncTask { + private final WeakReference mImageViewReference; + private User user; + private List mAsyncTasks; + private Object mFile; + private String mImageKey; + private FileDataStorageManager mStorageManager; + private GetMethod getMethod; + private Listener mListener; + private boolean gridViewEnabled = false; + + public ThumbnailGenerationTask(ImageView imageView, FileDataStorageManager storageManager, User user) + throws IllegalArgumentException { + this(imageView, storageManager, user, null); + } + + public ThumbnailGenerationTask(ImageView imageView, FileDataStorageManager storageManager, + User user, List asyncTasks) + throws IllegalArgumentException { + // Use a WeakReference to ensure the ImageView can be garbage collected + mImageViewReference = new WeakReference<>(imageView); + if (storageManager == null) { + throw new IllegalArgumentException("storageManager must not be NULL"); + } + mStorageManager = storageManager; + this.user = user; + mAsyncTasks = asyncTasks; + } + + public ThumbnailGenerationTask(ImageView imageView, + FileDataStorageManager storageManager, + User user, + List asyncTasks, + boolean gridViewEnabled, + String imageKey) + throws IllegalArgumentException { + this(imageView, storageManager, user, asyncTasks); + this.gridViewEnabled = gridViewEnabled; + mImageKey = imageKey; + } + + public GetMethod getGetMethod() { + return getMethod; + } + + public String getImageKey() { + return mImageKey; + } + + public ThumbnailGenerationTask(FileDataStorageManager storageManager, User user) { + if (storageManager == null) { + throw new IllegalArgumentException("storageManager must not be NULL"); + } + mStorageManager = storageManager; + this.user = user; + mImageViewReference = null; + } + + public ThumbnailGenerationTask(ImageView imageView) { + // Use a WeakReference to ensure the ImageView can be garbage collected + mImageViewReference = new WeakReference<>(imageView); + } + + @SuppressFBWarnings("Dm") + @Override + protected Bitmap doInBackground(ThumbnailGenerationTaskObject... params) { + Bitmap thumbnail = null; + try { + if (user != null) { + OwnCloudAccount ocAccount = user.toOwnCloudAccount(); + mClient = OwnCloudClientManagerFactory.getDefaultSingleton(). + getClientFor(ocAccount, MainApp.getAppContext()); + } + + ThumbnailGenerationTaskObject object = params[0]; + mFile = object.getFile(); + mImageKey = object.getImageKey(); + + if (mFile instanceof ServerFileInterface) { + thumbnail = doThumbnailFromOCFileInBackground(); + + if (MimeTypeUtil.isVideo((ServerFileInterface) mFile) && thumbnail != null) { + thumbnail = addVideoOverlay(thumbnail, MainApp.getAppContext()); + } + } else if (mFile instanceof File) { + thumbnail = doFileInBackground(); + + String url = ((File) mFile).getAbsolutePath(); + String mMimeType = FileStorageUtils.getMimeTypeFromName(url); + + if (MimeTypeUtil.isVideo(mMimeType) && thumbnail != null) { + thumbnail = addVideoOverlay(thumbnail, MainApp.getAppContext()); + } + //} else { do nothing + } + + } catch(OutOfMemoryError oome) { + Log_OC.e(TAG, "Out of memory"); + } catch (Throwable t) { + // the app should never break due to a problem with thumbnails + Log_OC.e(TAG, "Generation of thumbnail for " + mFile + " failed", t); + } + + return thumbnail; + } + + protected void onPostExecute(Bitmap bitmap) { + if (bitmap != null && mImageViewReference != null) { + final ImageView imageView = mImageViewReference.get(); + final ThumbnailGenerationTask bitmapWorkerTask = getBitmapWorkerTask(imageView); + + if (this == bitmapWorkerTask) { + String tagId = ""; + if (mFile instanceof OCFile) { + tagId = String.valueOf(((OCFile)mFile).getFileId()); + } else if (mFile instanceof File) { + tagId = String.valueOf(mFile.hashCode()); + } else if (mFile instanceof TrashbinFile) { + tagId = String.valueOf(((TrashbinFile) mFile).getRemoteId()); + } + if (String.valueOf(imageView.getTag()).equals(tagId)) { + if (gridViewEnabled) { + BitmapUtils.setRoundedBitmapForGridMode(bitmap, imageView); + } else { + BitmapUtils.setRoundedBitmap(bitmap, imageView); + } + } + } + + if (mListener != null) { + mListener.onSuccess(); + } + } else { + if (mListener != null) { + mListener.onError(); + } + } + + if (mAsyncTasks != null) { + mAsyncTasks.remove(this); + } + } + + public void setListener(Listener listener){ + mListener = listener; + } + + private Bitmap doThumbnailFromOCFileInBackground() { + Bitmap thumbnail; + ServerFileInterface file = (ServerFileInterface) mFile; + String imageKey = PREFIX_THUMBNAIL + file.getRemoteId(); + + boolean updateEnforced = (file instanceof OCFile && ((OCFile) file).isUpdateThumbnailNeeded()); + + // Try to load thumbnail from disk cache + if (!updateEnforced) { + thumbnail = getBitmapFromDiskCache(imageKey); + if (thumbnail != null) { + Log_OC.d(TAG, "Thumbnail found in disk cache for file: " + file.getFileName()); + return thumbnail; + } else { + Log_OC.d(TAG, "Thumbnail not found in cache for file: " + file.getFileName()); + } + } else { + Log_OC.d(TAG, "Thumbnail update enforced for file: " + file.getFileName()); + thumbnail = null; + } + + int pxW; + int pxH; + pxW = pxH = getThumbnailDimension(); + + // Generate thumbnail from local file if available + if (file instanceof OCFile ocFile && ocFile.isDown()) { + Log_OC.d(TAG, "Generating thumbnail from local file: " + ocFile.getFileName()); + + Bitmap bitmap; + if (MimeTypeUtil.isVideo(ocFile)) { + bitmap = ThumbnailUtils.createVideoThumbnail(ocFile.getStoragePath(), + MediaStore.Images.Thumbnails.MINI_KIND); + } else { + bitmap = BitmapUtils.decodeSampledBitmapFromFile(ocFile.getStoragePath(), pxW, pxH); + } + + if (bitmap != null) { + if (PNG_MIMETYPE.equalsIgnoreCase(ocFile.getMimeType())) { + bitmap = handlePNG(bitmap, pxW, pxH); + } + + thumbnail = addThumbnailToCache(imageKey, bitmap, ocFile.getStoragePath(), pxW, pxH); + ocFile.setUpdateThumbnailNeeded(false); + mStorageManager.saveFile(ocFile); + } + } + + // Check resized version in disk cache if still null + if (thumbnail == null) { + String resizedImageKey = PREFIX_RESIZED_IMAGE + file.getRemoteId(); + Bitmap resizedImage = null; + + if (!updateEnforced) { + resizedImage = getBitmapFromDiskCache(resizedImageKey); + } + + if (resizedImage != null) { + thumbnail = ThumbnailUtils.extractThumbnail(resizedImage, pxW, pxH); + Log_OC.d(TAG, "Thumbnail generated from resized image cache for file: " + file.getFileName()); + } else { + Log_OC.d(TAG, "No resized image cache available for file: " + file.getFileName()); + } + } + + // Download thumbnail from server if still null + if (thumbnail == null && mClient != null) { + Log_OC.d(TAG, "Attempting to download thumbnail from server for file: " + file.getFileName()); + GetMethod getMethod = null; + + try { + String uri; + if (file instanceof OCFile) { + uri = mClient.getBaseUri() + "/index.php/core/preview?fileId=" + + file.getLocalId() + + "&x=" + pxW + "&y=" + pxH + "&a=1&mode=cover&forceIcon=0"; + } else { + uri = mClient.getBaseUri() + "/index.php/apps/files_trashbin/preview?fileId=" + + file.getLocalId() + "&x=" + pxW + "&y=" + pxH; + } + + Log_OC.d(TAG, "Downloading thumbnail URI: " + uri); + + getMethod = new GetMethod(uri); + getMethod.setRequestHeader("Cookie", "nc_sameSiteCookielax=true;nc_sameSiteCookiestrict=true"); + getMethod.setRequestHeader(RemoteOperation.OCS_API_HEADER, RemoteOperation.OCS_API_HEADER_VALUE); + + int status = mClient.executeMethod(getMethod, READ_TIMEOUT, CONNECTION_TIMEOUT); + + if (status == HttpStatus.SC_OK) { + try (InputStream inputStream = getMethod.getResponseBodyAsStream()) { + Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + if (bitmap != null) { + thumbnail = ThumbnailUtils.extractThumbnail(bitmap, pxW, pxH); + Log_OC.d(TAG, "Thumbnail downloaded and extracted for file: " + file.getFileName()); + } else { + Log_OC.w(TAG, "Downloaded thumbnail bitmap is null for file: " + file.getFileName()); + } + } + } else { + mClient.exhaustResponse(getMethod.getResponseBodyAsStream()); + Log_OC.w(TAG, "Failed to download thumbnail, HTTP status: " + status); + } + + if (thumbnail != null && PNG_MIMETYPE.equalsIgnoreCase(file.getMimeType())) { + thumbnail = handlePNG(thumbnail, pxW, pxH); + Log_OC.d(TAG, "Handled PNG thumbnail for downloaded file: " + file.getFileName()); + } + } catch (Exception e) { + Log_OC.e(TAG, "Exception downloading thumbnail for file: " + file.getFileName(), e); + } finally { + if (getMethod != null) { + getMethod.releaseConnection(); + } + } + } + + // Add to disk cache if obtained + if (thumbnail != null) { + Log_OC.d(TAG, "Adding final thumbnail to cache for file: " + file.getFileName()); + addBitmapToCache(imageKey, thumbnail); + } else { + Log_OC.w(TAG, "Failed to obtain thumbnail for file: " + file.getFileName()); + } + + return thumbnail; + } + + /** + * Converts size of file icon from dp to pixel + * + * @return int + */ + private int getThumbnailDimension() { + // Converts dp to pixel + Resources r = MainApp.getAppContext().getResources(); + double d = Math.pow(2, Math.floor(Math.log(r.getDimension(R.dimen.file_icon_size_grid)) / Math.log(2))); + return (int) d; + } + + private Bitmap doFileInBackground() { + File file = (File)mFile; + + final String imageKey = Objects.requireNonNullElseGet(mImageKey, () -> String.valueOf(file.hashCode())); + + // local file should always generate a thumbnail + mImageKey = PREFIX_THUMBNAIL + mImageKey; + + // Check disk cache in background thread + Bitmap thumbnail = getBitmapFromDiskCache(imageKey); + + // Not found in disk cache + if (thumbnail == null) { + int pxW; + int pxH; + pxW = pxH = getThumbnailDimension(); + + Bitmap bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.getAbsolutePath(), pxW, pxH); + + if (bitmap != null) { + thumbnail = addThumbnailToCache(imageKey, bitmap, file.getPath(), pxW, pxH); + } + } + return thumbnail; + } + + public interface Listener{ + void onSuccess(); + void onError(); + } + + } + + public static class MediaThumbnailGenerationTask extends AsyncTask { + + private static final int IMAGE_KEY_PARAMS_LENGTH = 2; + + private enum Type {IMAGE, VIDEO} + + private final WeakReference mImageViewReference; + private File mFile; + private String mImageKey; + @SuppressLint("StaticFieldLeak") private final Context mContext; + private final ViewThemeUtils viewThemeUtils; + + public MediaThumbnailGenerationTask(ImageView imageView, + Context context, + ViewThemeUtils viewThemeUtils) { + // Use a WeakReference to ensure the ImageView can be garbage collected + mImageViewReference = new WeakReference<>(imageView); + mContext = context; + this.viewThemeUtils = viewThemeUtils; + } + + @Override + protected Bitmap doInBackground(Object... params) { + Bitmap thumbnail = null; + + try { + if (params[0] instanceof File) { + mFile = (File) params[0]; + if (params.length == IMAGE_KEY_PARAMS_LENGTH) { + mImageKey = (String) params[1]; + } + + if (MimeTypeUtil.isImage(mFile)) { + thumbnail = doFileInBackground(mFile, Type.IMAGE); + } else if (MimeTypeUtil.isVideo(mFile)) { + thumbnail = doFileInBackground(mFile, Type.VIDEO); + } + } + } // the app should never break due to a problem with thumbnails + catch (OutOfMemoryError t) { + Log_OC.e(TAG, "Generation of thumbnail for " + mFile.getAbsolutePath() + " failed", t); + Log_OC.e(TAG, "Out of memory"); + } catch (Throwable t) { + // the app should never break due to a problem with thumbnails + Log_OC.e(TAG, "Generation of thumbnail for " + mFile.getAbsolutePath() + " failed", t); + } + + return thumbnail; + } + + protected void onPostExecute(Bitmap bitmap) { + String tagId = ""; + final ImageView imageView = mImageViewReference.get(); + if (imageView != null) { + if (mFile != null) { + tagId = String.valueOf(mFile.hashCode()); + } + + if (bitmap != null) { + if (tagId.equals(String.valueOf(imageView.getTag()))) { + imageView.setImageBitmap(bitmap); + } + } else { + if (mFile != null) { + if (mFile.isDirectory()) { + imageView.setImageDrawable(MimeTypeUtil.getDefaultFolderIcon(mContext, viewThemeUtils)); + } else { + if (MimeTypeUtil.isVideo(mFile)) { + imageView.setImageBitmap(ThumbnailsCacheManager.mDefaultVideo); + } else { + imageView.setImageDrawable(MimeTypeUtil.getFileTypeIcon(null, + mFile.getName(), + mContext, + viewThemeUtils)); + } + } + } + } + } + } + + private Bitmap doFileInBackground(File file, Type type) { + final String imageKey = Objects.requireNonNullElseGet(mImageKey, () -> String.valueOf(file.hashCode())); + + // Check disk cache in background thread + Bitmap thumbnail = getBitmapFromDiskCache(imageKey); + + // Not found in disk cache + if (thumbnail == null) { + + if (Type.IMAGE == type) { + int px = getThumbnailDimension(); + + Bitmap bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.getAbsolutePath(), px, px); + + if (bitmap != null) { + thumbnail = addThumbnailToCache(imageKey, bitmap, file.getPath(), px, px); + } + } else if (Type.VIDEO == type) { + try (MediaMetadataRetriever retriever = new MediaMetadataRetriever()) { + retriever.setDataSource(file.getAbsolutePath()); + thumbnail = retriever.getFrameAtTime(-1); + } catch (Exception ex) { + // can't create a bitmap + Log_OC.w(TAG, "Failed to create bitmap from video " + file.getAbsolutePath()); + } + + if (thumbnail != null) { + // Scale down bitmap if too large. + int px = getThumbnailDimension(); + int width = thumbnail.getWidth(); + int height = thumbnail.getHeight(); + int max = Math.max(width, height); + if (max > px) { + thumbnail = BitmapUtils.scaleBitmap(thumbnail, px, width, height, max); + thumbnail = addThumbnailToCache(imageKey, thumbnail, file.getPath(), px, px); + } + } + } + } + + return thumbnail; + } + } + + public static class AvatarGenerationTask extends AsyncTask { + private final WeakReference mAvatarGenerationListener; + private final Object mCallContext; + private final Resources mResources; + private final float mAvatarRadius; + private final User user; + private final String mUserId; + private final String displayName; + private final String mServerName; + @SuppressLint("StaticFieldLeak") private final Context mContext; + + + public AvatarGenerationTask(AvatarGenerationListener avatarGenerationListener, + Object callContext, + User user, + Resources resources, + float avatarRadius, + String userId, + String displayName, + String serverName, + Context context) { + mAvatarGenerationListener = new WeakReference<>(avatarGenerationListener); + mCallContext = callContext; + this.user = user; + mResources = resources; + mAvatarRadius = avatarRadius; + mUserId = userId; + this.displayName = displayName; + mServerName = serverName; + mContext = context; + } + + @SuppressFBWarnings("Dm") + @Override + protected Drawable doInBackground(String... params) { + Drawable thumbnail = null; + + try { + thumbnail = doAvatarInBackground(); + } catch (OutOfMemoryError oome) { + Log_OC.e(TAG, "Out of memory"); + } catch (Throwable t) { + // the app should never break due to a problem with avatars + thumbnail = ResourcesCompat.getDrawable(mResources, R.drawable.account_circle_white, null); + Log_OC.e(TAG, "Generation of avatar for " + mUserId + " failed", t); + } + + return thumbnail; + } + + protected void onPostExecute(Drawable drawable) { + if (drawable != null) { + AvatarGenerationListener listener = mAvatarGenerationListener.get(); + if (listener != null) { + String accountName = mUserId + "@" + mServerName; + if (listener.shouldCallGeneratedCallback(accountName, mCallContext)) { + listener.avatarGenerated(drawable, mCallContext); + } + } + } + } + + private @NonNull + Drawable doAvatarInBackground() { + Bitmap avatar; + + String accountName = mUserId + "@" + mServerName; + + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(mContext); + + String eTag = arbitraryDataProvider.getValue(accountName, ThumbnailsCacheManager.AVATAR); + long timestamp = arbitraryDataProvider.getLongValue(accountName, ThumbnailsCacheManager.AVATAR_TIMESTAMP); + String avatarKey = "a_" + mUserId + "_" + mServerName + "_" + eTag; + avatar = getBitmapFromDiskCache(avatarKey); + + // Download avatar from server, only if older than 60 min or avatar does not exist + if (System.currentTimeMillis() - timestamp >= 60 * 60 * 1000 || avatar == null) { + GetMethod get = null; + try { + if (user != null) { + OwnCloudAccount ocAccount = user.toOwnCloudAccount(); + mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, mContext); + } + + int px = mResources.getInteger(R.integer.file_avatar_px); + String uri = mClient.getBaseUri() + "/index.php/avatar/" + Uri.encode(mUserId) + "/" + px; + Log_OC.d("Avatar", "URI: " + uri); + get = new GetMethod(uri); + + // only use eTag if available and corresponding avatar is still there + // (might be deleted from cache) + if (!eTag.isEmpty() && avatar != null) { + get.setRequestHeader("If-None-Match", eTag); + } + + int status = mClient.executeMethod(get); + + // we are using eTag to download a new avatar only if it changed + switch (status) { + case HttpStatus.SC_OK: + case HttpStatus.SC_CREATED: + // new avatar + InputStream inputStream = get.getResponseBodyAsStream(); + + String newETag = null; + if (get.getResponseHeader(ETAG) != null) { + newETag = get.getResponseHeader(ETAG).getValue().replace("\"", ""); + arbitraryDataProvider.storeOrUpdateKeyValue(accountName, AVATAR, newETag); + } + + Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + avatar = ThumbnailUtils.extractThumbnail(bitmap, px, px); + + // Add avatar to cache + if (avatar != null && !TextUtils.isEmpty(newETag)) { + avatar = handlePNG(avatar, px, px); + String newImageKey = "a_" + mUserId + "_" + mServerName + "_" + newETag; + addBitmapToCache(newImageKey, avatar); + arbitraryDataProvider.storeOrUpdateKeyValue(accountName, + ThumbnailsCacheManager.AVATAR_TIMESTAMP, + System.currentTimeMillis()); + } else { + return TextDrawable.createAvatar(user, mAvatarRadius); + } + break; + + case HttpStatus.SC_NOT_MODIFIED: + // old avatar + mClient.exhaustResponse(get.getResponseBodyAsStream()); + arbitraryDataProvider.storeOrUpdateKeyValue(accountName, + ThumbnailsCacheManager.AVATAR_TIMESTAMP, + System.currentTimeMillis()); + break; + default: + // everything else + mClient.exhaustResponse(get.getResponseBodyAsStream()); + break; + } + } catch (Exception e) { + try { + return TextDrawable.createAvatar(user, mAvatarRadius); + } catch (Exception e1) { + Log_OC.e(TAG, "Error generating fallback avatar"); + } + } finally { + if (get != null) { + get.releaseConnection(); + } + } + } + + if (avatar == null) { + try { + return TextDrawable.createAvatarByUserId(displayName, mAvatarRadius); + } catch (Exception e1) { + return ResourcesCompat.getDrawable(mResources, R.drawable.ic_user_outline, null); + } + } else { + return BitmapUtils.bitmapToCircularBitmapDrawable(mResources, avatar); + } + } + } + + public static boolean cancelPotentialThumbnailWork(Object file, ImageView imageView) { + final ThumbnailGenerationTask bitmapWorkerTask = getBitmapWorkerTask(imageView); + + if (bitmapWorkerTask != null) { + final Object bitmapData = bitmapWorkerTask.mFile; + // If bitmapData is not yet set or it differs from the new data + if (bitmapData == null || !bitmapData.equals(file)) { + // Cancel previous task + bitmapWorkerTask.cancel(true); + Log_OC.v(TAG, "Cancelled generation of thumbnail for a reused imageView"); + } else { + // The same work is already in progress + return false; + } + } + // No task associated with the ImageView, or an existing task was cancelled + return true; + } + + public static ThumbnailGenerationTask getBitmapWorkerTask(ImageView imageView) { + if (imageView != null) { + final Drawable drawable = imageView.getDrawable(); + if (drawable instanceof AsyncThumbnailDrawable asyncDrawable) { + return asyncDrawable.getBitmapWorkerTask(); + } + } + return null; + } + + private static ResizedImageGenerationTask getResizedImageGenerationWorkerTask(ImageView imageView) { + if (imageView != null) { + final Drawable drawable = imageView.getDrawable(); + if (drawable instanceof AsyncResizedImageDrawable asyncDrawable) { + return asyncDrawable.getBitmapWorkerTask(); + } + } + return null; + } + + public static Bitmap addVideoOverlay(Bitmap thumbnail, Context context) { + + Drawable playButtonDrawable = ResourcesCompat.getDrawable(MainApp.getAppContext().getResources(), + R.drawable.video_white, + null); + + int px = DisplayUtils.convertDpToPixel(24f, context); + + Bitmap playButton = BitmapUtils.drawableToBitmap(playButtonDrawable, px, px); + + Bitmap resizedPlayButton = Bitmap.createScaledBitmap(playButton, px, px, true); + + Bitmap resultBitmap = Bitmap.createBitmap(thumbnail.getWidth(), + thumbnail.getHeight(), + Bitmap.Config.ARGB_8888); + + Canvas c = new Canvas(resultBitmap); + + + c.drawBitmap(thumbnail, 0, 0, null); + + float left = (thumbnail.getWidth() - px) / 2f; + float top = (thumbnail.getHeight() - px) / 2f; + + Paint p = new Paint(); + p.setAlpha(230); + c.drawBitmap(resizedPlayButton, left, top, p); + + return resultBitmap; + } + + public static class AsyncThumbnailDrawable extends BitmapDrawable { + private final WeakReference bitmapWorkerTaskReference; + + public AsyncThumbnailDrawable( + Resources res, Bitmap bitmap, ThumbnailGenerationTask bitmapWorkerTask + ) { + + super(res, bitmap); + bitmapWorkerTaskReference = new WeakReference<>(bitmapWorkerTask); + } + + public ThumbnailGenerationTask getBitmapWorkerTask() { + return bitmapWorkerTaskReference.get(); + } + } + + public static class AsyncResizedImageDrawable extends BitmapDrawable { + private final WeakReference bitmapWorkerTaskReference; + + public AsyncResizedImageDrawable(Resources res, Bitmap bitmap, ResizedImageGenerationTask bitmapWorkerTask) { + super(res, bitmap); + bitmapWorkerTaskReference = new WeakReference<>(bitmapWorkerTask); + } + + private ResizedImageGenerationTask getBitmapWorkerTask() { + return bitmapWorkerTaskReference.get(); + } + } + + public static class AsyncMediaThumbnailDrawable extends BitmapDrawable { + + public AsyncMediaThumbnailDrawable(Resources res, Bitmap bitmap) { + + super(res, bitmap); + } + } + + /** + * adapted from ... + */ + public static Bitmap handlePNG(Bitmap source, int newWidth, int newHeight) { + Bitmap softwareBitmap = source.copy(Bitmap.Config.ARGB_8888, false); + + int sourceWidth = source.getWidth(); + int sourceHeight = source.getHeight(); + + float xScale = (float) newWidth / sourceWidth; + float yScale = (float) newHeight / sourceHeight; + float scale = Math.max(xScale, yScale); + + float scaledWidth = scale * sourceWidth; + float scaledHeight = scale * sourceHeight; + + float left = (newWidth - scaledWidth) / 2; + float top = (newHeight - scaledHeight) / 2; + + RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight); + + Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888); + + Canvas canvas = new Canvas(dest); + int color = ContextCompat.getColor(MainApp.getAppContext(),R.color.background_color_png); + canvas.drawColor(color); + canvas.drawBitmap(softwareBitmap, null, targetRect, null); + + return dest; + } + + public static void generateResizedImage(OCFile file) { + Point p = getScreenDimension(); + int pxW = p.x; + int pxH = p.y; + String imageKey = PREFIX_RESIZED_IMAGE + file.getRemoteId(); + + Bitmap bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.getStoragePath(), pxW, pxH); + + if (bitmap != null) { + // Handle PNG + if (PNG_MIMETYPE.equalsIgnoreCase(file.getMimeType())) { + bitmap = handlePNG(bitmap, pxW, pxH); + } + + addThumbnailToCache(imageKey, bitmap, file.getStoragePath(), pxW, pxH); + } + } + + public static void generateThumbnailFromOCFile(OCFile file, User user, Context context) { + int pxW; + int pxH; + pxW = pxH = getThumbnailDimension(); + String imageKey = PREFIX_THUMBNAIL + file.getRemoteId(); + + GetMethod getMethod = null; + + try { + Bitmap thumbnail = null; + + OwnCloudClient client = mClient; + if (client == null) { + OwnCloudAccount ocAccount = user.toOwnCloudAccount(); + client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context); + } + + String uri = client.getBaseUri() + "/index.php/apps/files/api/v1/thumbnail/" + + pxW + "/" + pxH + Uri.encode(file.getRemotePath(), "/"); + + Log_OC.d(TAG, "generate thumbnail: " + file.getFileName() + " URI: " + uri); + getMethod = new GetMethod(uri); + getMethod.setRequestHeader("Cookie", "nc_sameSiteCookielax=true;nc_sameSiteCookiestrict=true"); + + getMethod.setRequestHeader(RemoteOperation.OCS_API_HEADER, + RemoteOperation.OCS_API_HEADER_VALUE); + + int status = client.executeMethod(getMethod); + if (status == HttpStatus.SC_OK) { + InputStream inputStream = getMethod.getResponseBodyAsStream(); + Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + thumbnail = ThumbnailUtils.extractThumbnail(bitmap, pxW, pxH); + } else { + client.exhaustResponse(getMethod.getResponseBodyAsStream()); + } + + // Add thumbnail to cache + if (thumbnail != null) { + // Handle PNG + if (PNG_MIMETYPE.equalsIgnoreCase(file.getMimeType())) { + thumbnail = handlePNG(thumbnail, pxW, pxH); + } + + Log_OC.d(TAG, "add thumbnail to cache: " + file.getFileName()); + addBitmapToCache(imageKey, thumbnail); + } + } catch (Exception e) { + Log_OC.d(TAG, e.getMessage(), e); + } finally { + if (getMethod != null) { + getMethod.releaseConnection(); + } + } + } + + @VisibleForTesting + public static void clearCache() { + synchronized (mThumbnailsDiskCacheLock) { + if (mThumbnailCache != null) { + mThumbnailCache.clearCache(); + mThumbnailCache = null; + } + } + } + + public static void setClient(OwnCloudClient client) { + mClient = client; + } + + public static Bitmap doResizedImageInBackground(OCFile file, FileDataStorageManager storageManager) { + Bitmap thumbnail; + String imageKey = PREFIX_RESIZED_IMAGE + file.getRemoteId(); + + // Check disk cache in background thread + thumbnail = getBitmapFromDiskCache(imageKey); + if (thumbnail != null && !file.isUpdateThumbnailNeeded()) { + Log_OC.d(TAG, "Thumbnail found in cache"); + return thumbnail; + } + + Point p = getScreenDimension(); + int pxW = p.x; + int pxH = p.y; + + if (file.isDown() && MimeTypeUtil.isImage(file)) { + Bitmap bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.getStoragePath(), pxW, pxH); + if (bitmap != null) { + if (OCFileExtensionsKt.isPNG(file)) { + bitmap = handlePNG(bitmap, pxW, pxH); + } + thumbnail = addThumbnailToCache(imageKey, bitmap, file.getStoragePath(), pxW, pxH); + file.setUpdateThumbnailNeeded(false); + } + } else if (mClient != null) { + GetMethod getMethod = null; + + try { + String uri = OwnCloudClientExtensionsKt.getPreviewEndpoint(mClient, file.getRemoteId(), pxW, pxH); + Log_OC.d(TAG, "generating resized image: " + file.getFileName() + " URI: " + uri); + + getMethod = new GetMethod(uri); + getMethod.getParams().setSoTimeout(READ_TIMEOUT); + + int status = mClient.executeMethod(getMethod); + if (status == HttpStatus.SC_OK) { + try (InputStream inputStream = getMethod.getResponseBodyAsStream()) { + thumbnail = BitmapFactory.decodeStream(inputStream); + Log_OC.d(TAG, "resized image generated"); + } + } else { + Log_OC.e(TAG, "cannot generate thumbnail not supported file type, status: " + status + " file: " + file.getRemotePath()); + mClient.exhaustResponse(getMethod.getResponseBodyAsStream()); + } + + if (thumbnail != null && PNG_MIMETYPE.equalsIgnoreCase(file.getMimeType())) { + thumbnail = handlePNG(thumbnail, thumbnail.getWidth(), thumbnail.getHeight()); + } + + if (thumbnail != null) { + addBitmapToCache(imageKey, thumbnail); + } + } catch (Exception e) { + Log_OC.e(TAG, "doResizedBitmap: ", e); + } finally { + if (getMethod != null) { + getMethod.releaseConnection(); + } + } + } + + // resized dimensions and set update thumbnail needed to false to prevent rendering loop + if (thumbnail != null && file.getImageDimension() == null) { + file.setImageDimension(new ImageDimension(thumbnail.getWidth(), thumbnail.getHeight())); + file.setUpdateThumbnailNeeded(false); + storageManager.saveFile(file); + } + + return thumbnail; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt new file mode 100644 index 000000000000..73be317b47b5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt @@ -0,0 +1,595 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2024 Jonas Mayer + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019-2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2016-2020 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2016 María Asensio Valverde + * SPDX-FileCopyrightText: 2016 David A. Velasco + * SPDX-FileCopyrightText: 2014 Luke Owncloud + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.datamodel + +import android.content.ContentResolver +import android.content.ContentValues +import android.database.Cursor +import android.os.Handler +import android.os.Looper +import androidx.annotation.VisibleForTesting +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.account.User +import com.nextcloud.client.database.NextcloudDatabase +import com.nextcloud.client.database.dao.UploadDao +import com.nextcloud.client.database.entity.UploadEntity +import com.nextcloud.client.database.entity.toOCUpload +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.utils.autoRename.AutoRename +import com.nextcloud.utils.extensions.isConflict +import com.owncloud.android.MainApp +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.db.UploadResult +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.theme.CapabilityUtils +import java.util.Calendar +import java.util.Locale +import java.util.Observable + +@Suppress("TooManyFunctions", "TooGenericExceptionCaught", "MagicNumber", "ReturnCount") +class UploadsStorageManager( + private val currentAccountProvider: CurrentAccountProvider, + private val contentResolver: ContentResolver +) : Observable() { + + private var capability: OCCapability? = null + + val uploadDao: UploadDao = NextcloudDatabase.instance().uploadDao() + val fileSystemDao = NextcloudDatabase.instance().fileSystemDao() + val syncedFolderDao = NextcloudDatabase.instance().syncedFolderDao() + + private fun initOCCapability() { + try { + this.capability = CapabilityUtils.getCapability(MainApp.getAppContext()) + } catch (e: RuntimeException) { + Log_OC.e(TAG, "Failed to set OCCapability: Dependencies are not yet ready. $e") + } + } + + @Synchronized + fun updateUpload(ocUpload: OCUpload): Int { + val existingUpload = getUploadById(ocUpload.uploadId) + if (existingUpload == null) { + Log_OC.e(TAG, "Upload not found for ID: " + ocUpload.uploadId) + return 0 + } + + if (existingUpload.accountName != ocUpload.accountName) { + Log_OC.e( + TAG, + "Account mismatch for upload ID " + ocUpload.uploadId + + ": expected " + existingUpload.accountName + + ", got " + ocUpload.accountName + ) + return 0 + } + + Log_OC.v(TAG, "Updating " + ocUpload.localPath + " with status=" + ocUpload.uploadStatus) + + val cv = ContentValues().apply { + put(ProviderTableMeta.UPLOADS_LOCAL_PATH, ocUpload.localPath) + put(ProviderTableMeta.UPLOADS_REMOTE_PATH, ocUpload.remotePath) + put(ProviderTableMeta.UPLOADS_ACCOUNT_NAME, ocUpload.accountName) + put(ProviderTableMeta.UPLOADS_STATUS, ocUpload.uploadStatus.value) + put(ProviderTableMeta.UPLOADS_LAST_RESULT, ocUpload.lastResult.value) + + val uploadEndTimestamp = ocUpload.uploadEndTimestamp + put(ProviderTableMeta.UPLOADS_UPLOAD_END_TIMESTAMP_LONG, uploadEndTimestamp) + put(ProviderTableMeta.UPLOADS_FILE_SIZE, ocUpload.fileSize) + put(ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN, ocUpload.folderUnlockToken) + } + + val result = contentResolver.update( + ProviderTableMeta.CONTENT_URI_UPLOADS, + cv, + ProviderTableMeta._ID + "=? AND " + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + "=?", + arrayOf(ocUpload.uploadId.toString(), ocUpload.accountName) + ) + + Log_OC.d(TAG, "updateUpload returns with: " + result + " for file: " + ocUpload.localPath) + + if (result != SINGLE_RESULT) { + Log_OC.e(TAG, "Failed to update item " + ocUpload.localPath + " into upload db.") + } else { + notifyObserversNow() + } + + return result + } + + private fun updateUploadInternal( + c: Cursor, + status: UploadStatus?, + result: UploadResult?, + remotePath: String?, + localPath: String? + ): Int { + var r = 0 + while (c.moveToNext()) { + val upload = createOCUploadFromCursor(c) + + val path = c.getString(c.getColumnIndexOrThrow(ProviderTableMeta.UPLOADS_LOCAL_PATH)) + Log_OC.v( + TAG, + ( + "Updating " + path + " with status:" + status + " and result:" + + (result?.toString() ?: "null") + " (old:" + + upload.toFormattedString() + ')' + ) + ) + + upload.setUploadStatus(status) + upload.lastResult = result + upload.remotePath = remotePath + + if (localPath != null) { + upload.localPath = localPath + } + + if (status == UploadStatus.UPLOAD_SUCCEEDED) { + upload.uploadEndTimestamp = Calendar.getInstance().getTimeInMillis() + } + + r = updateUpload(upload) + } + + return r + } + + private fun updateUploadStatus( + id: Long, + status: UploadStatus?, + result: UploadResult?, + remotePath: String?, + localPath: String? + ) { + val c = contentResolver.query( + ProviderTableMeta.CONTENT_URI_UPLOADS, + null, + ProviderTableMeta._ID + "=?", + arrayOf(id.toString()), + null + ) + + if (c != null) { + if (c.count != SINGLE_RESULT) { + Log_OC.e( + TAG, + ( + c.count.toString() + " items for id=" + id + + " available in UploadDb. Expected 1. Failed to update upload db." + ) + ) + } else { + updateUploadInternal(c, status, result, remotePath, localPath) + } + c.close() + } else { + Log_OC.e(TAG, "Cursor is null") + } + } + + fun notifyObserversNow() { + Log_OC.d(TAG, "notifying upload storage manager observers") + Handler(Looper.getMainLooper()).post { + setChanged() + notifyObservers() + } + } + + fun removeUpload(upload: OCUpload?): Int = if (upload == null) 0 else removeUpload(upload.uploadId) + + fun removeUpload(id: Long): Int { + val result = contentResolver.delete( + ProviderTableMeta.CONTENT_URI_UPLOADS, + ProviderTableMeta._ID + "=?", + arrayOf(id.toString()) + ) + Log_OC.d(TAG, "delete returns $result for upload with id $id") + if (result > 0) { + notifyObserversNow() + } + return result + } + + private fun removeUpload(accountName: String?, remotePath: String?): Int { + val result = contentResolver.delete( + ProviderTableMeta.CONTENT_URI_UPLOADS, + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + "=? AND " + ProviderTableMeta.UPLOADS_REMOTE_PATH + "=?", + arrayOf(accountName, remotePath) + ) + Log_OC.d(TAG, "delete returns $result for file $remotePath in $accountName") + if (result > 0) { + notifyObserversNow() + } + return result + } + + fun removeUploads(accountName: String?): Int { + val result = contentResolver.delete( + ProviderTableMeta.CONTENT_URI_UPLOADS, + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + "=?", + arrayOf(accountName) + ) + Log_OC.d(TAG, "delete returns $result for uploads in $accountName") + if (result > 0) { + notifyObserversNow() + } + return result + } + + fun getAllStoredUploads(): Array = getUploads(null) + + fun getUploadById(id: Long): OCUpload? { + var result: OCUpload? = null + val cursor = contentResolver.query( + ProviderTableMeta.CONTENT_URI_UPLOADS, + null, + ProviderTableMeta._ID + "=?", + arrayOf(id.toString()), + "_id ASC" + ) + + if (cursor != null) { + if (cursor.moveToFirst()) { + result = createOCUploadFromCursor(cursor) + } + } + Log_OC.d(TAG, "Retrieve job $result for id $id") + return result + } + + fun getUploadsByIds(uploadIds: LongArray, accountName: String): List { + val result = ArrayList() + uploadDao.getUploadsByIds(uploadIds, accountName).forEach { entity -> + createOCUploadFromEntity(entity)?.let { result.add(it) } + } + return result + } + + private fun getUploads(selection: String?, vararg selectionArgs: String?): Array { + val uploads = ArrayList() + var page: Long = 0 + var rowsRead: Long + var rowsTotal: Long = 0 + var lastRowID: Long = -1 + + do { + val uploadsPage = getUploadPage(lastRowID, selection, *selectionArgs) + rowsRead = uploadsPage.size.toLong() + rowsTotal += rowsRead + if (uploadsPage.isNotEmpty()) { + lastRowID = uploadsPage.last().uploadId + } + Log_OC.v( + TAG, + String.format( + Locale.ENGLISH, + "getUploads() got %d rows from page %d, %d rows total so far, last ID %d", + rowsRead, + page, + rowsTotal, + lastRowID + ) + ) + uploads.addAll(uploadsPage) + page++ + } while (rowsRead > 0) + + Log_OC.v( + TAG, + String.format( + Locale.ENGLISH, + "getUploads() returning %d (%d) rows after reading %d pages", + rowsTotal, + uploads.size, + page + ) + ) + + return uploads.toTypedArray() + } + + private fun getUploadPage(afterId: Long, selection: String?, vararg selectionArgs: String?): List = + getUploadPage(QUERY_PAGE_SIZE, afterId, true, selection, *selectionArgs) + + private fun getUploadPage( + limit: Long, + afterId: Long, + descending: Boolean, + selection: String?, + vararg selectionArgs: String? + ): List { + val uploads = ArrayList() + val (sortDirection, idComparator) = if (descending) "DESC" to "<" else "ASC" to ">" + val pageSelection: String? + val pageSelectionArgs: Array + + if (afterId >= 0) { + pageSelection = if (selection != null) "($selection) AND _id $idComparator ?" else "_id $idComparator ?" + pageSelectionArgs = arrayOfNulls(selectionArgs.size + 1).also { arr -> + selectionArgs.forEachIndexed { i, v -> arr[i] = v } + arr[selectionArgs.size] = afterId.toString() + } + Log_OC.d(TAG, String.format(Locale.ENGLISH, "QUERY: %s ROWID: %d", pageSelection, afterId)) + } else { + pageSelection = selection + pageSelectionArgs = arrayOfNulls(selectionArgs.size).also { arr -> + selectionArgs.forEachIndexed { i, v -> arr[i] = v } + } + Log_OC.d(TAG, String.format(Locale.ENGLISH, "QUERY: %s ROWID: %d", selection, afterId)) + } + + val sortOrder = if (limit > 0) { + String.format(Locale.ENGLISH, "_id $sortDirection LIMIT %d", limit) + } else { + "_id $sortDirection" + } + + contentResolver.query( + ProviderTableMeta.CONTENT_URI_UPLOADS, + null, + pageSelection, + pageSelectionArgs, + sortOrder + )?.use { c -> + if (c.moveToFirst()) { + do { + uploads.add(createOCUploadFromCursor(c)) + } while (c.moveToNext() && !c.isAfterLast) + } + } + + return uploads + } + + private fun createOCUploadFromEntity(entity: UploadEntity?): OCUpload? { + if (entity == null) return null + initOCCapability() + return entity.toOCUpload(capability) + } + + private fun createOCUploadFromCursor(c: Cursor): OCUpload { + initOCCapability() + + fun Cursor.str(col: String): String = getString(getColumnIndexOrThrow(col)) + fun Cursor.int(col: String): Int = getInt(getColumnIndexOrThrow(col)) + fun Cursor.long(col: String): Long = getLong(getColumnIndexOrThrow(col)) + + var remotePath = c.str(ProviderTableMeta.UPLOADS_REMOTE_PATH) + if (capability != null) { + remotePath = AutoRename.rename(remotePath, capability!!) + } + + return OCUpload( + c.str(ProviderTableMeta.UPLOADS_LOCAL_PATH), + remotePath, + c.str(ProviderTableMeta.UPLOADS_ACCOUNT_NAME) + ).apply { + fileSize = c.long(ProviderTableMeta.UPLOADS_FILE_SIZE) + uploadId = c.long(ProviderTableMeta._ID) + setUploadStatus(UploadStatus.fromValue(c.int(ProviderTableMeta.UPLOADS_STATUS))) + localAction = c.int(ProviderTableMeta.UPLOADS_LOCAL_BEHAVIOUR) + nameCollisionPolicy = + NameCollisionPolicy.deserialize(c.int(ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY)) + isCreateRemoteFolder = c.int(ProviderTableMeta.UPLOADS_IS_CREATE_REMOTE_FOLDER) == 1 + + val timestampIndex = c.getColumnIndex(ProviderTableMeta.UPLOADS_UPLOAD_END_TIMESTAMP_LONG) + if (timestampIndex > -1) { + val ts = c.getLong(timestampIndex) + if (ts > 0) uploadEndTimestamp = ts + } + + lastResult = UploadResult.fromValue(c.int(ProviderTableMeta.UPLOADS_LAST_RESULT)) + createdBy = c.int(ProviderTableMeta.UPLOADS_CREATED_BY) + isUseWifiOnly = c.int(ProviderTableMeta.UPLOADS_IS_WIFI_ONLY) == 1 + isWhileChargingOnly = c.int(ProviderTableMeta.UPLOADS_IS_WHILE_CHARGING_ONLY) == 1 + folderUnlockToken = c.str(ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN) + } + } + + fun getCurrentUploadIds(accountName: String): LongArray = + uploadDao.getAllIds(UploadStatus.UPLOAD_IN_PROGRESS.value, accountName) + .stream() + .mapToLong { it.toLong() } + .toArray() + + fun getUploadsForAccount(accountName: String): Array = + getUploads(ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL, accountName) + + fun clearFailedButNotDelayedUploads() { + val user = currentAccountProvider.user + val deleted = contentResolver.delete( + ProviderTableMeta.CONTENT_URI_UPLOADS, + ProviderTableMeta.UPLOADS_STATUS + EQUAL + UploadStatus.UPLOAD_FAILED.value + + AND + ProviderTableMeta.UPLOADS_LAST_RESULT + ANGLE_BRACKETS + UploadResult.LOCK_FAILED.value + + AND + ProviderTableMeta.UPLOADS_LAST_RESULT + ANGLE_BRACKETS + UploadResult.DELAYED_FOR_WIFI.value + + AND + ProviderTableMeta.UPLOADS_LAST_RESULT + ANGLE_BRACKETS + UploadResult.DELAYED_FOR_CHARGING.value + + AND + ProviderTableMeta.UPLOADS_LAST_RESULT + ANGLE_BRACKETS + + UploadResult.DELAYED_IN_POWER_SAVE_MODE.value + + AND + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL, + arrayOf(user.accountName) + ) + + Log_OC.d(TAG, "delete all failed uploads but those delayed for Wifi") + + if (deleted > 0) notifyObserversNow() + } + + fun clearCancelledUploadsForCurrentAccount() { + val user = currentAccountProvider.user + val deleted = contentResolver.delete( + ProviderTableMeta.CONTENT_URI_UPLOADS, + ProviderTableMeta.UPLOADS_STATUS + EQUAL + UploadStatus.UPLOAD_CANCELLED.value + + AND + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL, + arrayOf(user.accountName) + ) + Log_OC.d(TAG, "delete all cancelled uploads") + if (deleted > 0) notifyObserversNow() + } + + fun clearSuccessfulUploads() { + val user = currentAccountProvider.user + val deleted = contentResolver.delete( + ProviderTableMeta.CONTENT_URI_UPLOADS, + ProviderTableMeta.UPLOADS_STATUS + EQUAL + UploadStatus.UPLOAD_SUCCEEDED.value + + AND + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL, + arrayOf(user.accountName) + ) + Log_OC.d(TAG, "delete all successful uploads") + if (deleted > 0) notifyObserversNow() + } + + fun updateDatabaseUploadResult(uploadResult: RemoteOperationResult<*>, upload: UploadFileOperation) { + Log_OC.d(TAG, "updateDatabaseUploadResult uploadResult: $uploadResult upload: $upload") + + if (uploadResult.isCancelled) { + Log_OC.w(TAG, "upload is cancelled, removing upload") + removeUpload(upload.user.accountName, upload.remotePath) + return + } + + val localPath = + if (upload.localBehaviour == FileUploadWorker.LOCAL_BEHAVIOUR_MOVE) upload.storagePath else null + + Log_OC.d(TAG, "local behaviour: " + upload.localBehaviour) + Log_OC.d(TAG, "local path of upload: $localPath") + + var status = UploadStatus.UPLOAD_FAILED + var result = UploadResult.fromOperationResult(uploadResult) + val code = uploadResult.code + + if (uploadResult.isSuccess) { + status = UploadStatus.UPLOAD_SUCCEEDED + result = UploadResult.UPLOADED + } else if (code.isConflict()) { + val isSame = FileUploadHelper().isSameFileOnRemote( + upload.user, + upload.storagePath, + upload.remotePath, + upload.context + ) + + if (isSame) { + result = UploadResult.SAME_FILE_CONFLICT + status = UploadStatus.UPLOAD_SUCCEEDED + } else { + result = UploadResult.SYNC_CONFLICT + } + } else if (code == RemoteOperationResult.ResultCode.LOCAL_FILE_NOT_FOUND) { + // upload status is SUCCEEDED because user cannot take action about it, it will always fail + status = UploadStatus.UPLOAD_SUCCEEDED + result = UploadResult.FILE_NOT_FOUND + } + + Log_OC.d( + TAG, + String.format( + "Upload Finished [%s] | RemoteCode: %s | internalResult: %s | FinalStatus: %s | Path: %s", + if (uploadResult.isSuccess) "✅" else "❌", + code, + result.name, + status, + upload.remotePath + ) + ) + + updateUploadStatus(upload.ocUploadId, status, result, upload.remotePath, localPath) + } + + fun updateDatabaseUploadStart(upload: UploadFileOperation) { + val localPath = + if (FileUploadWorker.LOCAL_BEHAVIOUR_MOVE == upload.localBehaviour) upload.storagePath else null + + updateUploadStatus( + upload.ocUploadId, + UploadStatus.UPLOAD_IN_PROGRESS, + UploadResult.UNKNOWN, + upload.remotePath, + localPath + ) + } + + @VisibleForTesting + fun removeAllUploads() { + Log_OC.v(TAG, "Delete all uploads!") + contentResolver.delete( + ProviderTableMeta.CONTENT_URI_UPLOADS, + "", + arrayOf() + ) + } + + fun removeUserUploads(user: User): Int { + Log_OC.v(TAG, "Delete all uploads for account " + user.accountName) + return contentResolver.delete( + ProviderTableMeta.CONTENT_URI_UPLOADS, + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + "=?", + arrayOf(user.accountName) + ) + } + + enum class UploadStatus(val value: Int) { + /** + * Upload currently in progress or scheduled to be executed. + */ + UPLOAD_IN_PROGRESS(0), + + /** + * Last upload failed. + */ + UPLOAD_FAILED(1), + + /** + * Upload was successful. + */ + UPLOAD_SUCCEEDED(2), + + /** + * Upload was cancelled by the user. + */ + UPLOAD_CANCELLED(3); + + companion object { + fun fromValue(value: Int): UploadStatus? = when (value) { + 0 -> UPLOAD_IN_PROGRESS + 1 -> UPLOAD_FAILED + 2 -> UPLOAD_SUCCEEDED + 3 -> UPLOAD_CANCELLED + else -> null + } + } + } + + companion object { + private val TAG: String = UploadsStorageManager::class.java.getSimpleName() + + private const val IS_EQUAL = "== ?" + private const val EQUAL = "==" + private const val OR = " OR " + private const val AND = " AND " + private const val ANGLE_BRACKETS = "<>" + private const val SINGLE_RESULT = 1 + + private const val QUERY_PAGE_SIZE: Long = 100 + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java new file mode 100644 index 000000000000..1259b706a2b1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.datamodel; + +/** + * Type for virtual folders + */ +public enum VirtualFolderType { + FAVORITE, GALLERY, NONE +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Data.java b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Data.java new file mode 100644 index 000000000000..da3ad49b4a20 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Data.java @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v1.decrypted; + +public class Data { + private String filename; + private String mimetype; + private String key; + private double version; + + public String getKey() { + return this.key; + } + + public String getFilename() { + return this.filename; + } + + public String getMimetype() { + return this.mimetype; + } + + public double getVersion() { + return this.version; + } + + public void setKey(String key) { + this.key = key; + } + + public void setFilename(String filename) { + this.filename = filename; + } + + public void setMimetype(String mimetype) { + this.mimetype = mimetype; + } + + public void setVersion(double version) { + this.version = version; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedFile.java b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedFile.java new file mode 100644 index 000000000000..dcd2d7888317 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedFile.java @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v1.decrypted; + +public class DecryptedFile { + private Data encrypted; + private String initializationVector; + private String authenticationTag; + private int metadataKey; + + public Data getEncrypted() { + return this.encrypted; + } + + public String getInitializationVector() { + return this.initializationVector; + } + + public String getAuthenticationTag() { + return this.authenticationTag; + } + + public int getMetadataKey() { + return this.metadataKey; + } + + public void setEncrypted(Data encrypted) { + this.encrypted = encrypted; + } + + public void setInitializationVector(String initializationVector) { + this.initializationVector = initializationVector; + } + + public void setAuthenticationTag(String authenticationTag) { + this.authenticationTag = authenticationTag; + } + + public void setMetadataKey(int metadataKey) { + this.metadataKey = metadataKey; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedFolderMetadataFileV1.java b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedFolderMetadataFileV1.java new file mode 100644 index 000000000000..c80021fa8fd2 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedFolderMetadataFileV1.java @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v1.decrypted; + +import java.util.HashMap; +import java.util.Map; + +import androidx.annotation.VisibleForTesting; + +/** + * Decrypted class representation of metadata json of folder metadata. + */ +public class DecryptedFolderMetadataFileV1 { + private DecryptedMetadata metadata; + private Map files; + private Map filedrop; + + public DecryptedFolderMetadataFileV1() { + this.metadata = new DecryptedMetadata(); + this.files = new HashMap<>(); + } + + public DecryptedFolderMetadataFileV1(DecryptedMetadata metadata, Map files) { + this.metadata = metadata; + this.files = files; + } + + public DecryptedMetadata getMetadata() { + return this.metadata; + } + + public Map getFiles() { + return this.files; + } + + public void setMetadata(DecryptedMetadata metadata) { + this.metadata = metadata; + } + + public void setFiles(Map files) { + this.files = files; + } + + @VisibleForTesting + public void setFiledrop(Map filedrop) { + this.filedrop = filedrop; + } + + public Map getFiledrop() { + return filedrop; + } + +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedMetadata.java b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedMetadata.java new file mode 100644 index 000000000000..2d2036eb5125 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/DecryptedMetadata.java @@ -0,0 +1,59 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v1.decrypted; + +import java.util.Map; + +public class DecryptedMetadata { + transient + private Map metadataKeys; // outdated with v1.1 + private String metadataKey; + private String checksum; + private double version; + + @Override + public String toString() { + return String.valueOf(version); + } + + public Map getMetadataKeys() { + return this.metadataKeys; + } + + public String getMetadataKey() { + if (metadataKey == null) { + // fallback to old keys array + return metadataKeys.get(0); + } + return metadataKey; + } + + public double getVersion() { + return this.version; + } + + public void setMetadataKeys(Map metadataKeys) { + this.metadataKeys = metadataKeys; + } + + public void setMetadataKey(String metadataKey) { + this.metadataKey = metadataKey; + } + + public void setVersion(double version) { + this.version = version; + } + + public String getChecksum() { + return checksum; + } + + public void setChecksum(String checksum) { + this.checksum = checksum; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Encrypted.java b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Encrypted.java new file mode 100644 index 000000000000..3d566c043b43 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Encrypted.java @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v1.decrypted; + +import java.util.Map; + +public class Encrypted { + private Map metadataKeys; + + public Map getMetadataKeys() { + return this.metadataKeys; + } + + public void setMetadataKeys(Map metadataKeys) { + this.metadataKeys = metadataKeys; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Sharing.java b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Sharing.java new file mode 100644 index 000000000000..6bb5b8941f67 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/decrypted/Sharing.java @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v1.decrypted; + +import java.util.Map; + +public class Sharing { + private Map recipient; + private String signature; + + public Map getRecipient() { + return this.recipient; + } + + public String getSignature() { + return this.signature; + } + + public void setRecipient(Map recipient) { + this.recipient = recipient; + } + + public void setSignature(String signature) { + this.signature = signature; + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/encrypted/EncryptedFile.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/encrypted/EncryptedFile.kt new file mode 100644 index 000000000000..63a957374d46 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/encrypted/EncryptedFile.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v1.encrypted + +import java.io.File + +class EncryptedFile(var encryptedFile: File, var authenticationTag: String) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/encrypted/EncryptedFolderMetadataFileV1.java b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/encrypted/EncryptedFolderMetadataFileV1.java new file mode 100644 index 000000000000..17d8eae891bd --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v1/encrypted/EncryptedFolderMetadataFileV1.java @@ -0,0 +1,86 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v1.encrypted; + +import com.owncloud.android.datamodel.EncryptedFiledrop; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata; + +import java.util.Map; + +/** + * Encrypted class representation of metadata json of folder metadata + */ +public class EncryptedFolderMetadataFileV1 { + private DecryptedMetadata metadata; + private Map files; + + private Map filedrop; + + public EncryptedFolderMetadataFileV1(DecryptedMetadata metadata, + Map files, + Map filesdrop) { + this.metadata = metadata; + this.files = files; + this.filedrop = filesdrop; + } + + public DecryptedMetadata getMetadata() { + return this.metadata; + } + + public Map getFiles() { + return files; + } + + public Map getFiledrop() { + return filedrop; + } + + public void setMetadata(DecryptedMetadata metadata) { + this.metadata = metadata; + } + + public void setFiles(Map files) { + this.files = files; + } + + public static class EncryptedFile { + private String encrypted; + private String initializationVector; + private String authenticationTag; + transient private int metadataKey; + + public String getEncrypted() { + return encrypted; + } + + public String getInitializationVector() { + return initializationVector; + } + + public String getAuthenticationTag() { + return authenticationTag; + } + + public int getMetadataKey() { + return metadataKey; + } + + public void setEncrypted(String encrypted) { + this.encrypted = encrypted; + } + + public void setInitializationVector(String initializationVector) { + this.initializationVector = initializationVector; + } + + public void setAuthenticationTag(String authenticationTag) { + this.authenticationTag = authenticationTag; + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedFile.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedFile.kt new file mode 100644 index 000000000000..406f85925a75 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedFile.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.decrypted + +data class DecryptedFile( + var filename: String, + val mimetype: String, + val nonce: String, + val authenticationTag: String, + val key: String +) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedFolderMetadataFile.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedFolderMetadataFile.kt new file mode 100644 index 000000000000..da5caae88827 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedFolderMetadataFile.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.decrypted + +import com.nextcloud.utils.e2ee.E2EVersionHelper + +/** + * Decrypted class representation of metadata json of folder metadata. + */ +data class DecryptedFolderMetadataFile( + val metadata: DecryptedMetadata, + var users: MutableList = mutableListOf(), + @Transient + val filedrop: MutableMap = HashMap(), + val version: String = E2EVersionHelper.latestVersion(true).value +) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedMetadata.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedMetadata.kt new file mode 100644 index 000000000000..7401571217c6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedMetadata.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.decrypted + +import com.owncloud.android.utils.EncryptionUtils + +data class DecryptedMetadata( + val keyChecksums: MutableList = mutableListOf(), + val deleted: Boolean = false, + var counter: Long = 0, + val folders: MutableMap = mutableMapOf(), + val files: MutableMap = mutableMapOf(), + @Transient + var metadataKey: ByteArray = EncryptionUtils.generateKey() +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DecryptedMetadata + + if (keyChecksums != other.keyChecksums) return false + if (deleted != other.deleted) return false + if (counter != other.counter) return false + if (folders != other.folders) return false + if (files != other.files) return false + + return true + } + + override fun hashCode(): Int { + var result = keyChecksums.hashCode() + result = 31 * result + deleted.hashCode() + result = 31 * result + counter.hashCode() + result = 31 * result + folders.hashCode() + result = 31 * result + files.hashCode() + return result + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedUser.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedUser.kt new file mode 100644 index 000000000000..ac02b66dfeb0 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/decrypted/DecryptedUser.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.decrypted + +data class DecryptedUser(val userId: String, val certificate: String, var decryptedMetadataKey: String?) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFiledrop.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFiledrop.kt new file mode 100644 index 000000000000..3f36adf8bd33 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFiledrop.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.encrypted + +data class EncryptedFiledrop( + val ciphertext: String, + val nonce: String, + val authenticationTag: String, + val users: List +) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFiledropUser.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFiledropUser.kt new file mode 100644 index 000000000000..bbe3e1c6ad94 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFiledropUser.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.encrypted + +data class EncryptedFiledropUser(val userId: String, val encryptedFiledropKey: String) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFolderMetadataFile.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFolderMetadataFile.kt new file mode 100644 index 000000000000..b7d13dfc13c9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedFolderMetadataFile.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.encrypted + +import com.nextcloud.utils.e2ee.E2EVersionHelper + +/** + * Decrypted class representation of metadata json of folder metadata. + */ +data class EncryptedFolderMetadataFile( + val metadata: EncryptedMetadata, + val users: List, + @Transient val filedrop: MutableMap?, + val version: String = E2EVersionHelper.latestVersion(true).value +) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedMetadata.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedMetadata.kt new file mode 100644 index 000000000000..c060aa7612b2 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedMetadata.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.encrypted + +data class EncryptedMetadata(val ciphertext: String, val nonce: String, val authenticationTag: String) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedUser.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedUser.kt new file mode 100644 index 000000000000..97a2c0af7ac3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/EncryptedUser.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.encrypted + +data class EncryptedUser(val userId: String, val certificate: String, val encryptedMetadataKey: String) diff --git a/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/FiledropData.kt b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/FiledropData.kt new file mode 100644 index 000000000000..bbefb6f66ca5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/e2e/v2/encrypted/FiledropData.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.datamodel.e2e.v2.encrypted + +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile + +class FiledropData { + private val files: Map = mutableMapOf() +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/quickPermission/QuickPermission.kt b/app/src/main/java/com/owncloud/android/datamodel/quickPermission/QuickPermission.kt new file mode 100644 index 000000000000..460edeb33350 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/quickPermission/QuickPermission.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel.quickPermission + +data class QuickPermission(val type: QuickPermissionType, var isSelected: Boolean) diff --git a/app/src/main/java/com/owncloud/android/datamodel/quickPermission/QuickPermissionType.kt b/app/src/main/java/com/owncloud/android/datamodel/quickPermission/QuickPermissionType.kt new file mode 100644 index 000000000000..83819443a209 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/quickPermission/QuickPermissionType.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel.quickPermission + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.owncloud.android.R +import com.owncloud.android.lib.resources.shares.OCShare + +enum class QuickPermissionType(val iconId: Int, val textId: Int) { + NONE(R.drawable.ic_unknown, R.string.unknown), + VIEW_ONLY(R.drawable.ic_eye, R.string.share_permission_view_only), + CAN_EDIT(R.drawable.ic_edit, R.string.share_permission_can_edit), + FILE_REQUEST(R.drawable.ic_file_request, R.string.share_permission_file_request), + SECURE_FILE_DROP(R.drawable.ic_file_request, R.string.create_end_to_end_encrypted_share), + CUSTOM_PERMISSIONS(R.drawable.ic_custom_permissions, R.string.share_custom_permission); + + fun getText(context: Context): String = context.getString(textId) + + fun getIcon(context: Context): Drawable? = ContextCompat.getDrawable(context, iconId) + + fun getPermissionFlag(isFolder: Boolean): Int = when (this) { + NONE -> OCShare.NO_PERMISSION + + VIEW_ONLY -> OCShare.READ_PERMISSION_FLAG + + CAN_EDIT -> if (isFolder) OCShare.MAXIMUM_PERMISSIONS_FOR_FOLDER else OCShare.MAXIMUM_PERMISSIONS_FOR_FILE + + FILE_REQUEST -> OCShare.CREATE_PERMISSION_FLAG + + SECURE_FILE_DROP -> OCShare.CREATE_PERMISSION_FLAG + OCShare.READ_PERMISSION_FLAG + + else -> { + // Custom permission's flag can't be determined + OCShare.NO_PERMISSION + } + } + + fun getAvailablePermissions(hasFileRequestPermission: Boolean): List { + val permissions = listOf(VIEW_ONLY, CAN_EDIT, FILE_REQUEST, CUSTOM_PERMISSIONS) + val result = if (hasFileRequestPermission) permissions else permissions.filter { it != FILE_REQUEST } + + return result.map { type -> + QuickPermission( + type = type, + isSelected = (type == this) + ) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/DataStorageProvider.java b/app/src/main/java/com/owncloud/android/datastorage/DataStorageProvider.java new file mode 100644 index 000000000000..bc213a45b705 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/DataStorageProvider.java @@ -0,0 +1,92 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.datastorage; + +import android.os.Environment; + +import com.owncloud.android.MainApp; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * @author Bartosz Przybylski + */ +public class DataStorageProvider { + + private static final UniqueStorageList mCachedStoragePoints = new UniqueStorageList(); + private static final DataStorageProvider sInstance = new DataStorageProvider(); + + public static DataStorageProvider getInstance() { + return sInstance; + } + + private DataStorageProvider() {} + + public StoragePoint[] getAvailableStoragePoints() { + if (!mCachedStoragePoints.isEmpty()) { + return mCachedStoragePoints.toArray(new StoragePoint[0]); + } + + StoragePoint storagePoint; + for (File f : MainApp.getAppContext().getExternalMediaDirs()) { + if (f != null) { + storagePoint = new StoragePoint(); + storagePoint.setPath(f.getAbsolutePath()); + storagePoint.setDescription(f.getAbsolutePath()); + storagePoint.setPrivacyType(StoragePoint.PrivacyType.PUBLIC); + if (f.getAbsolutePath().startsWith("/storage/emulated/0")) { + storagePoint.setStorageType(StoragePoint.StorageType.INTERNAL); + mCachedStoragePoints.add(storagePoint); + } else { + storagePoint.setStorageType(StoragePoint.StorageType.EXTERNAL); + if (isExternalStorageWritable()) { + mCachedStoragePoints.add(storagePoint); + } + } + } + } + + // Now we go add private ones + // Add internal storage directory + storagePoint = new StoragePoint(); + storagePoint.setDescription(MainApp.getAppContext().getFilesDir().getAbsolutePath()); + storagePoint.setPath(MainApp.getAppContext().getFilesDir().getAbsolutePath()); + storagePoint.setPrivacyType(StoragePoint.PrivacyType.PRIVATE); + storagePoint.setStorageType(StoragePoint.StorageType.INTERNAL); + mCachedStoragePoints.add(storagePoint); + + // Add external storage directory if available. + if (isExternalStorageWritable()) { + File externalFilesDir = MainApp.getAppContext().getExternalFilesDir(null); + + if (externalFilesDir != null) { + String externalFilesDirPath = externalFilesDir.getAbsolutePath(); + + storagePoint = new StoragePoint(); + storagePoint.setPath(externalFilesDirPath); + storagePoint.setDescription(externalFilesDirPath); + storagePoint.setPrivacyType(StoragePoint.PrivacyType.PRIVATE); + storagePoint.setStorageType(StoragePoint.StorageType.EXTERNAL); + mCachedStoragePoints.add(storagePoint); + } + } + + return mCachedStoragePoints.toArray(new StoragePoint[0]); + } + + /* Checks if external storage is available for read and write */ + public boolean isExternalStorageWritable() { + String state = Environment.getExternalStorageState(); + return Environment.MEDIA_MOUNTED.equals(state); + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/StoragePoint.java b/app/src/main/java/com/owncloud/android/datastorage/StoragePoint.java new file mode 100644 index 000000000000..8fa7d8305346 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/StoragePoint.java @@ -0,0 +1,77 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.datastorage; + +/** + * @author Bartosz Przybylski + */ +public class StoragePoint implements Comparable { + private String description; + private String path; + private StorageType storageType; + private PrivacyType privacyType; + + public StoragePoint(String description, String path, StorageType storageType, PrivacyType privacyType) { + this.description = description; + this.path = path; + this.storageType = storageType; + this.privacyType = privacyType; + } + + public StoragePoint() { + // empty constructor + } + + public String getDescription() { + return this.description; + } + + public String getPath() { + return this.path; + } + + public StorageType getStorageType() { + return this.storageType; + } + + public PrivacyType getPrivacyType() { + return this.privacyType; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setPath(String path) { + this.path = path; + } + + public void setStorageType(StorageType storageType) { + this.storageType = storageType; + } + + public void setPrivacyType(PrivacyType privacyType) { + this.privacyType = privacyType; + } + + public enum StorageType { + INTERNAL, EXTERNAL + } + + public enum PrivacyType { + PRIVATE, PUBLIC + } + + @Override + public int compareTo(StoragePoint another) { + return path.compareTo(another.getPath()); + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/UniqueStorageList.java b/app/src/main/java/com/owncloud/android/datastorage/UniqueStorageList.java new file mode 100644 index 000000000000..c421e55c2b65 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/UniqueStorageList.java @@ -0,0 +1,47 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.datastorage; + +import java.io.File; +import java.io.IOException; +import java.util.Collection; +import java.util.Vector; + +/** + * @author Bartosz Przybylski + */ +public class UniqueStorageList extends Vector { + private static final long serialVersionUID = -6504937852826767050L; + + @Override + public boolean add(StoragePoint sp) { + try { + for (StoragePoint s : this) { + String thisCanonPath = new File(s.getPath()).getCanonicalPath(); + String otherCanonPath = new File(sp.getPath()).getCanonicalPath(); + if (thisCanonPath.equals(otherCanonPath)) { + return true; + } + } + } catch (IOException e) { + return false; + } + return super.add(sp); + } + + @Override + public synchronized boolean addAll(Collection collection) { + for (StoragePoint sp : collection) { + add(sp); + } + return true; + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/providers/AbstractCommandLineStoragePoint.java b/app/src/main/java/com/owncloud/android/datastorage/providers/AbstractCommandLineStoragePoint.java new file mode 100644 index 000000000000..66dcb1216884 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/providers/AbstractCommandLineStoragePoint.java @@ -0,0 +1,57 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.datastorage.providers; + +import com.owncloud.android.lib.common.utils.Log_OC; + +import java.io.InputStream; +import java.util.Arrays; + +/** + * @author Bartosz Przybylski + */ +abstract class AbstractCommandLineStoragePoint extends AbstractStoragePointProvider { + private static final String TAG = AbstractCommandLineStoragePoint.class.getSimpleName(); + + private static final int COMMAND_LINE_OK_RETURN_VALUE = 0; + + protected abstract String[] getCommand(); + + @Override + public boolean canProvideStoragePoints() { + Process process; + try { + process = new ProcessBuilder().command(Arrays.asList(getCommand())).start(); + process.waitFor(); + } catch (Exception e) { + return false; + } + return process != null && process.exitValue() == COMMAND_LINE_OK_RETURN_VALUE; + } + + String getCommandLineResult() { + StringBuilder s = new StringBuilder(); + try { + final Process process = new ProcessBuilder().command(getCommand()).redirectErrorStream(true).start(); + + process.waitFor(); + final InputStream is = process.getInputStream(); + final byte[] buffer = new byte[1024]; + while (is.read(buffer) != -1) { + s.append(new String(buffer, "UTF8")); + } + is.close(); + } catch (final Exception e) { + Log_OC.e(TAG, "Error retrieving command line results!", e); + } + return s.toString(); + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/providers/AbstractStoragePointProvider.java b/app/src/main/java/com/owncloud/android/datastorage/providers/AbstractStoragePointProvider.java new file mode 100644 index 000000000000..bc9b9ff8baa3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/providers/AbstractStoragePointProvider.java @@ -0,0 +1,36 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.datastorage.providers; + +import com.owncloud.android.datastorage.StoragePoint; + +import java.io.File; + +/** + * @author Bartosz Przybylski + */ +abstract class AbstractStoragePointProvider implements IStoragePointProvider { + + boolean canBeAddedToAvailableList(Iterable currentList, String path) { + if (path == null) { + return false; + } + + for (StoragePoint storage : currentList) { + if (storage.getPath().equals(path)) { + return false; + } + } + + File f = new File(path); + return f.exists() && f.isDirectory() && f.canRead() && f.canWrite(); + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/providers/EnvironmentStoragePointProvider.java b/app/src/main/java/com/owncloud/android/datastorage/providers/EnvironmentStoragePointProvider.java new file mode 100644 index 000000000000..868dee9cbaa1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/providers/EnvironmentStoragePointProvider.java @@ -0,0 +1,52 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.datastorage.providers; + +import android.text.TextUtils; + +import com.owncloud.android.datastorage.StoragePoint; + +import java.util.List; +import java.util.Vector; + +/** + * @author Bartosz Przybylski + */ +public class EnvironmentStoragePointProvider extends AbstractStoragePointProvider { + + private static final String sSecondaryStorageEnvName = "SECONDARY_STORAGE"; + + @Override + public boolean canProvideStoragePoints() { + return !TextUtils.isEmpty(System.getenv(sSecondaryStorageEnvName)); + } + + @Override + public List getAvailableStoragePoint() { + List result = new Vector<>(); + + addEntriesFromEnv(result, sSecondaryStorageEnvName); + + return result; + } + + private void addEntriesFromEnv(List result, String envName) { + String env = System.getenv(envName); + if (env != null) { + for (String p : env.split(":")) { + if (canBeAddedToAvailableList(result, p)) { + result.add(new StoragePoint(p, p, StoragePoint.StorageType.EXTERNAL, + StoragePoint.PrivacyType.PUBLIC)); + } + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/providers/HardcodedStoragePointProvider.java b/app/src/main/java/com/owncloud/android/datastorage/providers/HardcodedStoragePointProvider.java new file mode 100644 index 000000000000..dcd6026ada01 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/providers/HardcodedStoragePointProvider.java @@ -0,0 +1,47 @@ +/** + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.datastorage.providers; + +import com.owncloud.android.datastorage.StoragePoint; + +import java.util.Vector; + +/** + * @author Bartosz Przybylski + */ +public class HardcodedStoragePointProvider extends AbstractStoragePointProvider { + + private static final String[] PATHS = { + "/mnt/external_sd/", + "/mnt/extSdCard/", + "/storage/extSdCard", + "/storage/sdcard1/", + "/storage/usbcard1/" + }; + + @Override + public boolean canProvideStoragePoints() { + return true; + } + + @Override + public Vector getAvailableStoragePoint() { + Vector result = new Vector<>(); + + for (String s : PATHS) { + if (canBeAddedToAvailableList(result, s)) { + result.add(new StoragePoint(s, s, StoragePoint.StorageType.EXTERNAL, StoragePoint.PrivacyType.PUBLIC)); + } + } + + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/providers/IStoragePointProvider.java b/app/src/main/java/com/owncloud/android/datastorage/providers/IStoragePointProvider.java new file mode 100644 index 000000000000..5f8f38b312c1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/providers/IStoragePointProvider.java @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2016-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.datastorage.providers; + +import com.owncloud.android.datastorage.StoragePoint; + +import java.util.List; + +public interface IStoragePointProvider { + + /** + * This method is used for querying storage provider to check if it can provide + * usable and reliable data storage places. + * + * @return true if provider can reliably return storage path + */ + boolean canProvideStoragePoints(); + + /** + * + * @return available storage points + */ + List getAvailableStoragePoint(); +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/providers/MountCommandStoragePointProvider.java b/app/src/main/java/com/owncloud/android/datastorage/providers/MountCommandStoragePointProvider.java new file mode 100644 index 000000000000..2cfc52c011f1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/providers/MountCommandStoragePointProvider.java @@ -0,0 +1,62 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.datastorage.providers; + +import com.owncloud.android.datastorage.StoragePoint; + +import java.util.List; +import java.util.Locale; +import java.util.Vector; +import java.util.regex.Pattern; + +/** + * @author Bartosz Przybylski + */ +public class MountCommandStoragePointProvider extends AbstractCommandLineStoragePoint { + + static private final String[] sCommand = new String[] { "mount" }; + + private static Pattern sPattern = Pattern.compile("(?i).*vold.*(vfat|ntfs|exfat|fat32|ext3|ext4).*rw.*"); + + @Override + protected String[] getCommand() { + return sCommand; + } + + @Override + public List getAvailableStoragePoint() { + List result = new Vector<>(); + + for (String p : getPotentialPaths(getCommandLineResult())) { + if (canBeAddedToAvailableList(result, p)) { + result.add(new StoragePoint(p, p, StoragePoint.StorageType.EXTERNAL, StoragePoint.PrivacyType.PUBLIC)); + } + } + + return result; + } + + private List getPotentialPaths(String mounted) { + final List result = new Vector<>(); + + for (String line : mounted.split("\n")) { + if (!line.toLowerCase(Locale.US).contains("asec") && sPattern.matcher(line).matches()) { + String[] parts = line.split(" "); + for (String path : parts) { + if (path.length() > 0 && path.charAt(0) == '/' && !path.toLowerCase(Locale.US).contains("vold")) { + result.add(path); + } + } + } + } + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/providers/SystemDefaultStoragePointProvider.java b/app/src/main/java/com/owncloud/android/datastorage/providers/SystemDefaultStoragePointProvider.java new file mode 100644 index 000000000000..7706a778ae38 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/providers/SystemDefaultStoragePointProvider.java @@ -0,0 +1,40 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ + +package com.owncloud.android.datastorage.providers; + +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.datastorage.StoragePoint; + +import java.util.Vector; + +/** + * @author Bartosz Przybylski + */ +public class SystemDefaultStoragePointProvider extends AbstractStoragePointProvider { + @Override + public boolean canProvideStoragePoints() { + return true; + } + + @Override + public Vector getAvailableStoragePoint() { + Vector result = new Vector<>(); + + final String defaultStringDesc = MainApp.string(R.string.storage_description_default); + // Add private internal storage data directory. + result.add(new StoragePoint(defaultStringDesc, + MainApp.getAppContext().getFilesDir().getAbsolutePath(), StoragePoint.StorageType.INTERNAL, + StoragePoint.PrivacyType.PRIVATE)); + + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/datastorage/providers/VDCStoragePointProvider.java b/app/src/main/java/com/owncloud/android/datastorage/providers/VDCStoragePointProvider.java new file mode 100644 index 000000000000..f2d695937801 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datastorage/providers/VDCStoragePointProvider.java @@ -0,0 +1,68 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * Copyright (C) 2016 Bartosz Przybylski + * + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.datastorage.providers; + +import com.owncloud.android.datastorage.StoragePoint; +import com.owncloud.android.lib.common.utils.Log_OC; + +import java.util.List; +import java.util.Vector; + +/** + * @author Bartosz Przybylski + */ +public class VDCStoragePointProvider extends AbstractCommandLineStoragePoint { + + static private final String TAG = VDCStoragePointProvider.class.getSimpleName(); + + static private final String[] sVDCVolListCommand = new String[]{ "/system/bin/vdc", "volume", "list" }; + static private final int sVDCVolumeList = 110; + + + @Override + public List getAvailableStoragePoint() { + + return new Vector<>(getPaths(getCommandLineResult())); + } + + @Override + protected String[] getCommand() { + return sVDCVolListCommand; + } + + private List getPaths(String vdcResources) { + List result = new Vector<>(); + + for (String line : vdcResources.split("\n")) { + String[] vdcLine = line.split(" "); + try { + int status = Integer.parseInt(vdcLine[0]); + if (status != sVDCVolumeList) { + continue; + } + final String description = vdcLine[1]; + final String path = vdcLine[2]; + + if (canBeAddedToAvailableList(result, path)) { + result.add(new StoragePoint(description, path, StoragePoint.StorageType.EXTERNAL, + StoragePoint.PrivacyType.PRIVATE)); + } + + } catch (NumberFormatException e) { + Log_OC.e(TAG, "Incorrect VDC output format " + e); + } catch (Exception e) { + Log_OC.e(TAG, "Unexpected exception on VDC parsing " + e); + } + } + + return result; + } + +} diff --git a/app/src/main/java/com/owncloud/android/db/OCUpload.java b/app/src/main/java/com/owncloud/android/db/OCUpload.java new file mode 100644 index 000000000000..6632fcf12537 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/db/OCUpload.java @@ -0,0 +1,451 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Alice Gaudon + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-FileCopyrightText: 2017 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 Luke Owncloud + * SPDX-FileCopyrightText: 2015-2016 David A. Velasco + * SPDX-FileCopyrightText: 2015-2016 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.db; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.operations.UploadFileOperation; +import com.owncloud.android.utils.MimeTypeUtil; + +import java.io.File; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * Stores all information in order to start upload operations. PersistentUploadObject can + * be stored persistently by {@link UploadsStorageManager}. + */ +public class OCUpload implements Parcelable { + + private static final String TAG = OCUpload.class.getSimpleName(); + + private long uploadId; + + /** + * Absolute path in the local file system to the file to be uploaded. + */ + private String localPath; + + /** + * Absolute path in the remote account to set to the uploaded file (not for its parent folder!) + */ + private String remotePath; + + /** + * Name of Owncloud account to upload file to. + */ + private String accountName; + + /** + * File size. + */ + private long fileSize; + + /** + * Local action for upload. (0 - COPY, 1 - MOVE, 2 - FORGET) + */ + private int localAction; + + /** + * What to do in case of name collision. + */ + private NameCollisionPolicy nameCollisionPolicy; + + /** + * Create destination folder? + */ + private boolean createRemoteFolder; + + /** + * Status of upload (later, in_progress, ...). + */ + private UploadStatus uploadStatus; + + /** + * Result from last upload operation. Can be null. + */ + private UploadResult lastResult; + + /** + * Defines the origin of the upload; see constants CREATED_ in {@link UploadFileOperation} + */ + private int createdBy; + + /** + * When the upload ended + */ + private long uploadEndTimestamp; + + /** + * Upload only via wifi? + */ + private boolean useWifiOnly; + + /** + * Upload only if phone being charged? + */ + private boolean whileChargingOnly; + + /** + * Token to unlock E2E folder + */ + private String folderUnlockToken; + + /** + * temporary values, used for sorting + */ + private UploadStatus fixedUploadStatus; + private boolean fixedUploadingNow; + private long fixedUploadEndTimeStamp; + private long fixedUploadId; + + /** + * Main constructor. + * + * @param localPath Absolute path in the local file system to the file to be uploaded. + * @param remotePath Absolute path in the remote account to set to the uploaded file. + * @param accountName Name of an file owner account. + */ + public OCUpload(String localPath, String remotePath, String accountName) { + if (localPath == null || !localPath.startsWith(File.separator)) { + Log_OC.e(TAG, "oc upload, local path: " + localPath); + throw new IllegalArgumentException("Local path must be an absolute path in the local file system"); + } + if (remotePath == null || !remotePath.startsWith(OCFile.PATH_SEPARATOR)) { + Log_OC.e(TAG, "oc upload, remote path: " + remotePath); + throw new IllegalArgumentException("Remote path must be an absolute path in the local file system"); + } + if (accountName == null || accountName.length() < 1) { + throw new IllegalArgumentException("Invalid account name"); + } + resetData(); + this.localPath = localPath; + this.remotePath = remotePath; + this.accountName = accountName; + } + + /** + * Convenience constructor to re-upload already existing {@link OCFile}s. + * + * @param ocFile {@link OCFile} instance to update in the remote server. + * @param user file owner + */ + public OCUpload(OCFile ocFile, User user) { + this(ocFile.getStoragePath(), ocFile.getRemotePath(), user.getAccountName()); + } + + /** + * Reset all the fields to default values. + */ + private void resetData() { + remotePath = ""; + localPath = ""; + accountName = ""; + fileSize = -1; + uploadId = -1; + localAction = FileUploadWorker.LOCAL_BEHAVIOUR_COPY; + nameCollisionPolicy = NameCollisionPolicy.DEFAULT; + createRemoteFolder = false; + uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS; + lastResult = UploadResult.UNKNOWN; + createdBy = UploadFileOperation.CREATED_BY_USER; + useWifiOnly = true; + whileChargingOnly = false; + folderUnlockToken = ""; + } + + public void setDataFixed(FileUploadHelper uploadHelper) { + fixedUploadStatus = uploadStatus; + fixedUploadingNow = uploadHelper != null && uploadHelper.isUploadingNow(this); + fixedUploadEndTimeStamp = uploadEndTimestamp; + fixedUploadId = uploadId; + } + + // custom Getters & Setters + /** + * Sets uploadStatus AND SETS lastResult = null; + * @param uploadStatus the uploadStatus to set + */ + public void setUploadStatus(UploadStatus uploadStatus) { + this.uploadStatus = uploadStatus; + setLastResult(UploadResult.UNKNOWN); + } + + /** + * @param lastResult the lastResult to set + */ + public void setLastResult(UploadResult lastResult) { + this.lastResult = lastResult != null ? lastResult : UploadResult.UNKNOWN; + } + + public UploadStatus getFixedUploadStatus() { + return fixedUploadStatus; + } + + public boolean isFixedUploadingNow() { + return fixedUploadingNow; + } + + public long getFixedUploadEndTimeStamp() { + return fixedUploadEndTimeStamp; + } + + public long getFixedUploadId() { + return fixedUploadId; + } + + /** + * @return the mimeType + */ + public String getMimeType() { + return MimeTypeUtil.getBestMimeTypeByFilename(localPath); + } + + /** + * For debugging purposes only. + */ + public String toFormattedString() { + try { + String localPath = getLocalPath() != null ? getLocalPath() : ""; + return localPath + " status:" + getUploadStatus() + " result:" + + (getLastResult() == null ? "null" : getLastResult().getValue()); + } catch (NullPointerException e) { + Log_OC.d(TAG, "Exception", e); + return e.toString(); + } + } + + /**** + * + */ + public static final Parcelable.Creator CREATOR = new Parcelable.Creator<>() { + + @Override + public OCUpload createFromParcel(Parcel source) { + return new OCUpload(source); + } + + @Override + public OCUpload[] newArray(int size) { + return new OCUpload[size]; + } + }; + + /** + * Reconstruct from parcel + * + * @param source The source parcel + */ + private OCUpload(Parcel source) { + readFromParcel(source); + } + + private void readFromParcel(Parcel source) { + uploadId = source.readLong(); + localPath = source.readString(); + remotePath = source.readString(); + accountName = source.readString(); + localAction = source.readInt(); + nameCollisionPolicy = NameCollisionPolicy.deserialize(source.readInt()); + createRemoteFolder = source.readInt() == 1; + try { + uploadStatus = UploadStatus.valueOf(source.readString()); + } catch (IllegalArgumentException x) { + uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS; + } + uploadEndTimestamp = source.readLong(); + try { + lastResult = UploadResult.valueOf(source.readString()); + } catch (IllegalArgumentException x) { + lastResult = UploadResult.UNKNOWN; + } + createdBy = source.readInt(); + useWifiOnly = source.readInt() == 1; + whileChargingOnly = source.readInt() == 1; + folderUnlockToken = source.readString(); + } + + @Override + public int describeContents() { + return this.hashCode(); + } + + + public boolean isSame(@Nullable Object obj) { + return isSame(obj, false); + } + + @SuppressFBWarnings("SEO_SUBOPTIMAL_EXPRESSION_ORDER") + @VisibleForTesting + public boolean isSame(@Nullable Object obj, boolean ignoreUploadId) { + if (!(obj instanceof OCUpload other)) { + return false; + } + return (ignoreUploadId || this.uploadId == other.uploadId) && + localPath.equals(other.localPath) && + remotePath.equals(other.remotePath) && + accountName.equals(other.accountName) && + localAction == other.localAction && + nameCollisionPolicy == other.nameCollisionPolicy && + createRemoteFolder == other.createRemoteFolder && + uploadStatus == other.uploadStatus && + lastResult == other.lastResult && + createdBy == other.createdBy && + useWifiOnly == other.useWifiOnly && + whileChargingOnly == other.whileChargingOnly && + folderUnlockToken.equals(other.folderUnlockToken); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(uploadId); + dest.writeString(localPath); + dest.writeString(remotePath); + dest.writeString(accountName); + dest.writeInt(localAction); + dest.writeInt(nameCollisionPolicy.serialize()); + dest.writeInt(createRemoteFolder ? 1 : 0); + dest.writeString(uploadStatus.name()); + dest.writeLong(uploadEndTimestamp); + dest.writeString(lastResult == null ? "" : lastResult.name()); + dest.writeInt(createdBy); + dest.writeInt(useWifiOnly ? 1 : 0); + dest.writeInt(whileChargingOnly ? 1 : 0); + dest.writeString(folderUnlockToken); + } + + public long getUploadId() { + return this.uploadId; + } + + public String getLocalPath() { + return this.localPath; + } + + public String getRemotePath() { + return this.remotePath; + } + + public String getAccountName() { + return this.accountName; + } + + public long getFileSize() { + return this.fileSize; + } + + public int getLocalAction() { + return this.localAction; + } + + public NameCollisionPolicy getNameCollisionPolicy() { + return this.nameCollisionPolicy; + } + + public boolean isCreateRemoteFolder() { + return this.createRemoteFolder; + } + + public UploadStatus getUploadStatus() { + return this.uploadStatus; + } + + public UploadResult getLastResult() { + return this.lastResult; + } + + public int getCreatedBy() { + return this.createdBy; + } + + public long getUploadEndTimestamp() { + return this.uploadEndTimestamp; + } + + public boolean isUseWifiOnly() { + return this.useWifiOnly; + } + + public boolean exists() { + return new File(localPath).exists(); + } + + public boolean isWhileChargingOnly() { + return this.whileChargingOnly; + } + + public String getFolderUnlockToken() { + return this.folderUnlockToken; + } + + public void setUploadId(long uploadId) { + this.uploadId = uploadId; + } + + public void setLocalPath(String localPath) { + this.localPath = localPath; + } + + public void setRemotePath(String remotePath) { + this.remotePath = remotePath; + } + + public void setFileSize(long fileSize) { + this.fileSize = fileSize; + } + + public void setLocalAction(int localAction) { + this.localAction = localAction; + } + + public void setNameCollisionPolicy(NameCollisionPolicy nameCollisionPolicy) { + this.nameCollisionPolicy = nameCollisionPolicy; + } + + public void setCreateRemoteFolder(boolean createRemoteFolder) { + this.createRemoteFolder = createRemoteFolder; + } + + public void setCreatedBy(int createdBy) { + this.createdBy = createdBy; + } + + public void setUploadEndTimestamp(long uploadEndTimestamp) { + this.uploadEndTimestamp = uploadEndTimestamp; + } + + public void setUseWifiOnly(boolean useWifiOnly) { + this.useWifiOnly = useWifiOnly; + } + + public void setWhileChargingOnly(boolean whileChargingOnly) { + this.whileChargingOnly = whileChargingOnly; + } + + public void setFolderUnlockToken(String folderUnlockToken) { + this.folderUnlockToken = folderUnlockToken; + } +} diff --git a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java new file mode 100644 index 000000000000..e12b0b44ec54 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java @@ -0,0 +1,378 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2016-2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2014-2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-FileCopyrightText: 2011 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.db; + +import android.net.Uri; +import android.provider.BaseColumns; + +import com.owncloud.android.MainApp; + +import java.util.List; + +/** + * Meta-Class that holds various static field information + */ +public class ProviderMeta { + public static final String DB_NAME = "filelist"; + public static final int DB_VERSION = 100; + + private ProviderMeta() { + // No instance + } + + static public class ProviderTableMeta implements BaseColumns { + // region Recommended files table + public static final String RECOMMENDED_FILE_TABLE_NAME = "recommended_files"; + public static final String RECOMMENDED_FILE_NAME = "name"; + public static final String RECOMMENDED_FILE_ACCOUNT_NAME = "account_name"; + public static final String RECOMMENDED_FILE_DIRECTORY = "directory"; + public static final String RECOMMENDED_FILE_EXTENSIONS = "extension"; + public static final String RECOMMENDED_FILE_MIME_TYPE = "mime_type"; + public static final String RECOMMENDED_FILE_HAS_PREVIEW = "has_preview"; + public static final String RECOMMENDED_FILE_REASON = "reason"; + public static final String RECOMMENDED_TIMESTAMP = "timestamp"; + // endregion + + // region Table names + public static final String OFFLINE_OPERATION_TABLE_NAME = "offline_operations"; + public static final String FILE_TABLE_NAME = "filelist"; + public static final String OCSHARES_TABLE_NAME = "ocshares"; + public static final String CAPABILITIES_TABLE_NAME = "capabilities"; + public static final String UPLOADS_TABLE_NAME = "list_of_uploads"; + public static final String SYNCED_FOLDERS_TABLE_NAME = "synced_folders"; + public static final String EXTERNAL_LINKS_TABLE_NAME = "external_links"; + public static final String ARBITRARY_DATA_TABLE_NAME = "arbitrary_data"; + public static final String VIRTUAL_TABLE_NAME = "virtual"; + public static final String ASSISTANT_TABLE_NAME = "assistant"; + public static final String FILESYSTEM_TABLE_NAME = "filesystem"; + public static final String EDITORS_TABLE_NAME = "editors"; + public static final String CREATORS_TABLE_NAME = "creators"; + // endregion + + private static final String CONTENT_PREFIX = "content://"; + + public static final Uri CONTENT_URI = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/"); + public static final Uri CONTENT_URI_FILE = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/file"); + public static final Uri CONTENT_URI_DIR = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/dir"); + public static final Uri CONTENT_URI_SHARE = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/shares"); + public static final Uri CONTENT_URI_CAPABILITIES = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/capabilities"); + public static final Uri CONTENT_URI_UPLOADS = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/uploads"); + public static final Uri CONTENT_URI_SYNCED_FOLDERS = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/synced_folders"); + public static final Uri CONTENT_URI_EXTERNAL_LINKS = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/external_links"); + public static final Uri CONTENT_URI_VIRTUAL = Uri.parse(CONTENT_PREFIX + MainApp.getAuthority() + "/virtual"); + public static final Uri CONTENT_URI_FILESYSTEM = Uri.parse(CONTENT_PREFIX + + MainApp.getAuthority() + "/filesystem"); + + + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.owncloud.file"; + public static final String CONTENT_TYPE_ITEM = "vnd.android.cursor.item/vnd.owncloud.file"; + + // Columns of filelist table + public static final String FILE_PARENT = "parent"; + public static final String FILE_NAME = "filename"; + public static final String FILE_ENCRYPTED_NAME = "encrypted_filename"; + public static final String FILE_CREATION = "created"; + public static final String FILE_MODIFIED = "modified"; + public static final String FILE_UPLOADED = "uploaded"; + public static final String FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA = "modified_at_last_sync_for_data"; + public static final String FILE_CONTENT_LENGTH = "content_length"; + public static final String FILE_CONTENT_TYPE = "content_type"; + public static final String FILE_STORAGE_PATH = "media_path"; + public static final String FILE_PATH = "path"; + public static final String FILE_PATH_DECRYPTED = "path_decrypted"; + public static final String FILE_ACCOUNT_OWNER = "file_owner"; + public static final String FILE_LAST_SYNC_DATE = "last_sync_date";// _for_properties, but let's keep it as it is + public static final String FILE_LAST_SYNC_DATE_FOR_DATA = "last_sync_date_for_data"; + public static final String FILE_KEEP_IN_SYNC = "keep_in_sync"; + public static final String FILE_ETAG = "etag"; + public static final String FILE_ETAG_ON_SERVER = "etag_on_server"; + public static final String FILE_SHARED_VIA_LINK = "share_by_link"; + public static final String FILE_SHARED_WITH_SHAREE = "shared_via_users"; + public static final String FILE_PERMISSIONS = "permissions"; + public static final String FILE_LOCAL_ID = "local_id"; + public static final String FILE_REMOTE_ID = "remote_id"; + public static final String FILE_UPDATE_THUMBNAIL = "update_thumbnail"; + public static final String FILE_IS_DOWNLOADING = "is_downloading"; + public static final String FILE_ETAG_IN_CONFLICT = "etag_in_conflict"; + public static final String FILE_FAVORITE = "favorite"; + public static final String FILE_HIDDEN = "hidden"; + public static final String FILE_IS_ENCRYPTED = "is_encrypted"; + public static final String FILE_MOUNT_TYPE = "mount_type"; + public static final String FILE_HAS_PREVIEW = "has_preview"; + public static final String FILE_UNREAD_COMMENTS_COUNT = "unread_comments_count"; + public static final String FILE_OWNER_ID = "owner_id"; + public static final String FILE_OWNER_DISPLAY_NAME = "owner_display_name"; + public static final String FILE_NOTE = "note"; + public static final String FILE_SHAREES = "sharees"; + public static final String FILE_RICH_WORKSPACE = "rich_workspace"; + public static final String FILE_METADATA_SIZE = "metadata_size"; + public static final String FILE_METADATA_GPS = "metadata_gps"; + public static final String FILE_METADATA_LIVE_PHOTO = "metadata_live_photo"; + public static final String FILE_LOCKED = "locked"; + public static final String FILE_LOCK_TYPE = "lock_type"; + public static final String FILE_LOCK_OWNER = "lock_owner"; + public static final String FILE_LOCK_OWNER_DISPLAY_NAME = "lock_owner_display_name"; + public static final String FILE_LOCK_OWNER_EDITOR = "lock_owner_editor"; + public static final String FILE_LOCK_TIMESTAMP = "lock_timestamp"; + public static final String FILE_LOCK_TIMEOUT = "lock_timeout"; + public static final String FILE_LOCK_TOKEN = "lock_token"; + public static final String FILE_TAGS = "tags"; + public static final String FILE_E2E_COUNTER = "e2e_counter"; + public static final String FILE_INTERNAL_TWO_WAY_SYNC_TIMESTAMP = "internal_two_way_sync_timestamp"; + public static final String FILE_INTERNAL_TWO_WAY_SYNC_RESULT = "internal_two_way_sync_result"; + + public static final List FILE_ALL_COLUMNS = List.of(_ID, + FILE_PARENT, + FILE_NAME, + FILE_ENCRYPTED_NAME, + FILE_UPLOADED, + FILE_CREATION, + FILE_MODIFIED, + FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA, + FILE_CONTENT_LENGTH, + FILE_CONTENT_TYPE, + FILE_STORAGE_PATH, + FILE_PATH, + FILE_PATH_DECRYPTED, + FILE_ACCOUNT_OWNER, + FILE_LAST_SYNC_DATE, + FILE_LAST_SYNC_DATE_FOR_DATA, + FILE_KEEP_IN_SYNC, + FILE_ETAG, + FILE_ETAG_ON_SERVER, + FILE_SHARED_VIA_LINK, + FILE_SHARED_WITH_SHAREE, + FILE_PERMISSIONS, + FILE_REMOTE_ID, + FILE_LOCAL_ID, + FILE_UPDATE_THUMBNAIL, + FILE_IS_DOWNLOADING, + FILE_ETAG_IN_CONFLICT, + FILE_FAVORITE, + FILE_HIDDEN, + FILE_IS_ENCRYPTED, + FILE_MOUNT_TYPE, + FILE_HAS_PREVIEW, + FILE_UNREAD_COMMENTS_COUNT, + FILE_OWNER_ID, + FILE_OWNER_DISPLAY_NAME, + FILE_NOTE, + FILE_SHAREES, + FILE_RICH_WORKSPACE, + FILE_LOCKED, + FILE_LOCK_TYPE, + FILE_LOCK_OWNER, + FILE_LOCK_OWNER_DISPLAY_NAME, + FILE_LOCK_OWNER_EDITOR, + FILE_LOCK_TIMESTAMP, + FILE_LOCK_TIMEOUT, + FILE_LOCK_TOKEN, + FILE_METADATA_SIZE, + FILE_METADATA_LIVE_PHOTO, + FILE_E2E_COUNTER, + FILE_TAGS, + FILE_METADATA_GPS, + FILE_INTERNAL_TWO_WAY_SYNC_TIMESTAMP, + FILE_INTERNAL_TWO_WAY_SYNC_RESULT); + public static final String FILE_DEFAULT_SORT_ORDER = FILE_NAME + " collate nocase asc"; + + // Columns of ocshares table + public static final String OCSHARES_FILE_SOURCE = "file_source"; + public static final String OCSHARES_ITEM_SOURCE = "item_source"; + public static final String OCSHARES_SHARE_TYPE = "share_type"; + public static final String OCSHARES_SHARE_WITH = "shate_with"; + public static final String OCSHARES_PATH = "path"; + public static final String OCSHARES_PERMISSIONS = "permissions"; + public static final String OCSHARES_SHARED_DATE = "shared_date"; + public static final String OCSHARES_EXPIRATION_DATE = "expiration_date"; + public static final String OCSHARES_TOKEN = "token"; + public static final String OCSHARES_SHARE_WITH_DISPLAY_NAME = "shared_with_display_name"; + public static final String OCSHARES_IS_DIRECTORY = "is_directory"; + public static final String OCSHARES_USER_ID = "user_id"; + public static final String OCSHARES_ID_REMOTE_SHARED = "id_remote_shared"; + public static final String OCSHARES_ACCOUNT_OWNER = "owner_share"; + public static final String OCSHARES_IS_PASSWORD_PROTECTED = "is_password_protected"; + public static final String OCSHARES_NOTE = "note"; + public static final String OCSHARES_HIDE_DOWNLOAD = "hide_download"; + public static final String OCSHARES_SHARE_LINK = "share_link"; + public static final String OCSHARES_SHARE_LABEL = "share_label"; + public static final String OCSHARES_DOWNLOADLIMIT_LIMIT = "download_limit_limit"; + public static final String OCSHARES_DOWNLOADLIMIT_COUNT = "download_limit_count"; + public static final String OCSHARES_ATTRIBUTES = "attributes"; + + public static final String OCSHARES_DEFAULT_SORT_ORDER = OCSHARES_FILE_SOURCE + + " collate nocase asc"; + + // Columns of capabilities table + public static final String CAPABILITIES_ACCOUNT_NAME = "account"; + public static final String CAPABILITIES_VERSION_MAYOR = "version_mayor"; + public static final String CAPABILITIES_VERSION_MINOR = "version_minor"; + public static final String CAPABILITIES_VERSION_MICRO = "version_micro"; + public static final String CAPABILITIES_VERSION_STRING = "version_string"; + public static final String CAPABILITIES_VERSION_EDITION = "version_edition"; + public static final String CAPABILITIES_EXTENDED_SUPPORT = "extended_support"; + public static final String CAPABILITIES_CORE_POLLINTERVAL = "core_pollinterval"; + public static final String CAPABILITIES_SHARING_API_ENABLED = "sharing_api_enabled"; + public static final String CAPABILITIES_SHARING_PUBLIC_ENABLED = "sharing_public_enabled"; + public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED = "sharing_public_password_enforced"; + public static final String CAPABILITIES_SHARING_PUBLIC_ASK_FOR_OPTIONAL_PASSWORD = + "sharing_public_ask_for_optional_password"; + public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENABLED = + "sharing_public_expire_date_enabled"; + public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_DAYS = + "sharing_public_expire_date_days"; + public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENFORCED = + "sharing_public_expire_date_enforced"; + public static final String CAPABILITIES_SHARING_PUBLIC_SEND_MAIL = "sharing_public_send_mail"; + public static final String CAPABILITIES_SHARING_PUBLIC_UPLOAD = "sharing_public_upload"; + public static final String CAPABILITIES_SHARING_USER_SEND_MAIL = "sharing_user_send_mail"; + public static final String CAPABILITIES_SHARING_RESHARING = "sharing_resharing"; + public static final String CAPABILITIES_SHARING_FEDERATION_OUTGOING = "sharing_federation_outgoing"; + public static final String CAPABILITIES_SHARING_FEDERATION_INCOMING = "sharing_federation_incoming"; + public static final String CAPABILITIES_FILES_BIGFILECHUNKING = "files_bigfilechunking"; + public static final String CAPABILITIES_FILES_UNDELETE = "files_undelete"; + public static final String CAPABILITIES_FILES_VERSIONING = "files_versioning"; + public static final String CAPABILITIES_FILES_LOCKING_VERSION = "files_locking_version"; + public static final String CAPABILITIES_EXTERNAL_LINKS = "external_links"; + public static final String CAPABILITIES_SERVER_NAME = "server_name"; + public static final String CAPABILITIES_SERVER_COLOR = "server_color"; + public static final String CAPABILITIES_SERVER_TEXT_COLOR = "server_text_color"; + public static final String CAPABILITIES_SERVER_ELEMENT_COLOR = "server_element_color"; + public static final String CAPABILITIES_SERVER_BACKGROUND_URL = "background_url"; + public static final String CAPABILITIES_SERVER_SLOGAN = "server_slogan"; + public static final String CAPABILITIES_SERVER_LOGO = "server_logo"; + public static final String CAPABILITIES_SERVER_BACKGROUND_DEFAULT = "background_default"; + public static final String CAPABILITIES_SERVER_BACKGROUND_PLAIN = "background_plain"; + public static final String CAPABILITIES_END_TO_END_ENCRYPTION = "end_to_end_encryption"; + public static final String CAPABILITIES_END_TO_END_ENCRYPTION_KEYS_EXIST = "end_to_end_encryption_keys_exist"; + public static final String CAPABILITIES_END_TO_END_ENCRYPTION_API_VERSION = "end_to_end_encryption_api_version"; + public static final String CAPABILITIES_ACTIVITY = "activity"; + public static final String CAPABILITIES_RICHDOCUMENT = "richdocument"; + public static final String CAPABILITIES_RICHDOCUMENT_MIMETYPE_LIST = "richdocument_mimetype_list"; + public static final String CAPABILITIES_RICHDOCUMENT_OPTIONAL_MIMETYPE_LIST = + "richdocument_optional_mimetype_list"; + public static final String CAPABILITIES_RICHDOCUMENT_DIRECT_EDITING = "richdocument_direct_editing"; + public static final String CAPABILITIES_RICHDOCUMENT_TEMPLATES = "richdocument_direct_templates"; + public static final String CAPABILITIES_RICHDOCUMENT_PRODUCT_NAME = "richdocument_product_name"; + public static final String CAPABILITIES_DEFAULT_SORT_ORDER = CAPABILITIES_ACCOUNT_NAME + + " collate nocase asc"; + public static final String CAPABILITIES_DIRECT_EDITING_ETAG = "direct_editing_etag"; + public static final String CAPABILITIES_ETAG = "etag"; + public static final String CAPABILITIES_USER_STATUS = "user_status"; + public static final String CAPABILITIES_USER_STATUS_SUPPORTS_EMOJI = "user_status_supports_emoji"; + public static final String CAPABILITIES_USER_STATUS_SUPPORTS_BUSY = "user_status_supports_busy"; + public static final String CAPABILITIES_ASSISTANT = "assistant"; + public static final String CAPABILITIES_GROUPFOLDERS = "groupfolders"; + public static final String CAPABILITIES_DROP_ACCOUNT = "drop_account"; + public static final String CAPABILITIES_SECURITY_GUARD = "security_guard"; + public static final String CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS = "forbidden_filename_characters"; + public static final String CAPABILITIES_FORBIDDEN_FILENAMES = "forbidden_filenames"; + public static final String CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_EXTENSIONS = "forbidden_filename_extensions"; + public static final String CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_BASE_NAMES = "forbidden_filename_basenames"; + public static final String CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES = "windows_compatible_filenames"; + public static final String CAPABILITIES_FILES_DOWNLOAD_LIMIT = "files_download_limit"; + public static final String CAPABILITIES_FILES_DOWNLOAD_LIMIT_DEFAULT = "files_download_limit_default"; + public static final String CAPABILITIES_NOTES_FOLDER_PATH = "notes_folder_path"; + public static final String CAPABILITIES_DEFAULT_PERMISSIONS = "default_permissions"; + public static final String CAPABILITIES_HAS_VALID_SUBSCRIPTION = "has_valid_subscription"; + public static final String CAPABILITIES_CLIENT_INTEGRATION_JSON = "client_integration_json"; + + //Columns of Uploads table + public static final String UPLOADS_LOCAL_PATH = "local_path"; + public static final String UPLOADS_REMOTE_PATH = "remote_path"; + public static final String UPLOADS_ACCOUNT_NAME = "account_name"; + public static final String UPLOADS_FILE_SIZE = "file_size"; + public static final String UPLOADS_STATUS = "status"; + public static final String UPLOADS_LOCAL_BEHAVIOUR = "local_behaviour"; + public static final String UPLOADS_UPLOAD_TIME = "upload_time"; + public static final String UPLOADS_NAME_COLLISION_POLICY = "name_collision_policy"; + public static final String UPLOADS_IS_CREATE_REMOTE_FOLDER = "is_create_remote_folder"; + public static final String UPLOADS_UPLOAD_END_TIMESTAMP = "upload_end_timestamp"; + public static final String UPLOADS_UPLOAD_END_TIMESTAMP_LONG = "upload_end_timestamp_long"; + + public static final String UPLOADS_LAST_RESULT = "last_result"; + public static final String UPLOADS_CREATED_BY = "created_by"; + public static final String UPLOADS_DEFAULT_SORT_ORDER = ProviderTableMeta._ID + " collate nocase desc"; + public static final String UPLOADS_IS_WHILE_CHARGING_ONLY = "is_while_charging_only"; + public static final String UPLOADS_IS_WIFI_ONLY = "is_wifi_only"; + public static final String UPLOADS_FOLDER_UNLOCK_TOKEN = "folder_unlock_token"; + + // Columns of offline operation table + public static final String OFFLINE_OPERATION_PARENT_OC_FILE_ID = "offline_operations_parent_oc_file_id"; + public static final String OFFLINE_OPERATION_TYPE = "offline_operations_type"; + public static final String OFFLINE_OPERATION_PATH = "offline_operations_path"; + public static final String OFFLINE_OPERATION_MODIFIED_AT = "offline_operations_modified_at"; + public static final String OFFLINE_OPERATION_CREATED_AT = "offline_operations_created_at"; + public static final String OFFLINE_OPERATION_FILE_NAME = "offline_operations_file_name"; + + + // Columns of synced folder table + public static final String SYNCED_FOLDER_LOCAL_PATH = "local_path"; + public static final String SYNCED_FOLDER_REMOTE_PATH = "remote_path"; + public static final String SYNCED_FOLDER_WIFI_ONLY = "wifi_only"; + public static final String SYNCED_FOLDER_CHARGING_ONLY = "charging_only"; + public static final String SYNCED_FOLDER_EXISTING = "existing"; + public static final String SYNCED_FOLDER_ENABLED = "enabled"; + public static final String SYNCED_FOLDER_ENABLED_TIMESTAMP_MS = "enabled_timestamp_ms"; + public static final String SYNCED_FOLDER_TYPE = "type"; + public static final String SYNCED_FOLDER_SUBFOLDER_BY_DATE = "subfolder_by_date"; + public static final String SYNCED_FOLDER_ACCOUNT = "account"; + public static final String SYNCED_FOLDER_UPLOAD_ACTION = "upload_option"; + public static final String SYNCED_FOLDER_NAME_COLLISION_POLICY = "name_collision_policy"; + public static final String SYNCED_FOLDER_HIDDEN = "hidden"; + public static final String SYNCED_FOLDER_SUBFOLDER_RULE = "sub_folder_rule"; + public static final String SYNCED_FOLDER_EXCLUDE_HIDDEN = "exclude_hidden"; + public static final String SYNCED_FOLDER_LAST_SCAN_TIMESTAMP_MS = "last_scan_timestamp_ms"; + + // Columns of external links table + public static final String EXTERNAL_LINKS_ICON_URL = "icon_url"; + public static final String EXTERNAL_LINKS_LANGUAGE = "language"; + public static final String EXTERNAL_LINKS_TYPE = "type"; + public static final String EXTERNAL_LINKS_NAME = "name"; + public static final String EXTERNAL_LINKS_URL = "url"; + public static final String EXTERNAL_LINKS_REDIRECT = "redirect"; + + // Columns of arbitrary data table + public static final String ARBITRARY_DATA_CLOUD_ID = "cloud_id"; + public static final String ARBITRARY_DATA_KEY = "key"; + public static final String ARBITRARY_DATA_VALUE = "value"; + + + // Columns of virtual + public static final String VIRTUAL_TYPE = "type"; + public static final String VIRTUAL_OCFILE_ID = "ocfile_id"; + + // Columns of filesystem data table + public static final String FILESYSTEM_FILE_LOCAL_PATH = "local_path"; + public static final String FILESYSTEM_FILE_REMOTE_PATH = "remote_path"; + public static final String FILESYSTEM_FILE_MODIFIED = "modified_at"; + public static final String FILESYSTEM_FILE_IS_FOLDER = "is_folder"; + public static final String FILESYSTEM_FILE_FOUND_RECENTLY = "found_at"; + public static final String FILESYSTEM_FILE_SENT_FOR_UPLOAD = "upload_triggered"; + public static final String FILESYSTEM_SYNCED_FOLDER_ID = "syncedfolder_id"; + public static final String FILESYSTEM_CRC32 = "crc32"; + + public static final String CAPABILITIES_RECOMMENDATION = "recommendation"; + + private ProviderTableMeta() { + // No instance + } + } +} diff --git a/app/src/main/java/com/owncloud/android/db/UploadResult.java b/app/src/main/java/com/owncloud/android/db/UploadResult.java new file mode 100644 index 000000000000..79f25d9a4db6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/db/UploadResult.java @@ -0,0 +1,123 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2016-2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2016 David A. Velasco + * SPDX-FileCopyrightText: 2015-2016 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.db; + +import com.owncloud.android.lib.common.operations.RemoteOperationResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public enum UploadResult { + UNKNOWN(-1), + UPLOADED(0), + NETWORK_CONNECTION(1), + CREDENTIAL_ERROR(2), + FOLDER_ERROR(3), + CONFLICT_ERROR(4), + FILE_ERROR(5), + PRIVILEGES_ERROR(6), + CANCELLED(7), + FILE_NOT_FOUND(8), + DELAYED_FOR_WIFI(9), + SERVICE_INTERRUPTED(10), + DELAYED_FOR_CHARGING(11), + MAINTENANCE_MODE(12), + LOCK_FAILED(13), + DELAYED_IN_POWER_SAVE_MODE(14), + SSL_RECOVERABLE_PEER_UNVERIFIED(15), + VIRUS_DETECTED(16), + LOCAL_STORAGE_FULL(17), + OLD_ANDROID_API(18), + SYNC_CONFLICT(19), + CANNOT_CREATE_FILE(20), + LOCAL_STORAGE_NOT_COPIED(21), + QUOTA_EXCEEDED(22), + SAME_FILE_CONFLICT(23); + + private final int value; + + UploadResult(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static final List CONFLICT_ERRORS = List.of( + UploadResult.CONFLICT_ERROR, + UploadResult.SYNC_CONFLICT); + + private static final Map valueMap = Map.ofEntries( + Map.entry(0, UPLOADED), + Map.entry(1, NETWORK_CONNECTION), + Map.entry(2, CREDENTIAL_ERROR), + Map.entry(3, FOLDER_ERROR), + Map.entry(4, CONFLICT_ERROR), + Map.entry(5, FILE_ERROR), + Map.entry(6, PRIVILEGES_ERROR), + Map.entry(7, CANCELLED), + Map.entry(8, FILE_NOT_FOUND), + Map.entry(9, DELAYED_FOR_WIFI), + Map.entry(10, SERVICE_INTERRUPTED), + Map.entry(11, DELAYED_FOR_CHARGING), + Map.entry(12, MAINTENANCE_MODE), + Map.entry(13, LOCK_FAILED), + Map.entry(14, DELAYED_IN_POWER_SAVE_MODE), + Map.entry(15, SSL_RECOVERABLE_PEER_UNVERIFIED), + Map.entry(16, VIRUS_DETECTED), + Map.entry(17, LOCAL_STORAGE_FULL), + Map.entry(18, OLD_ANDROID_API), + Map.entry(19, SYNC_CONFLICT), + Map.entry(20, CANNOT_CREATE_FILE), + Map.entry(21, LOCAL_STORAGE_NOT_COPIED), + Map.entry(22, QUOTA_EXCEEDED), + Map.entry(23, SAME_FILE_CONFLICT) + ); + public static UploadResult fromValue(int value) { + return valueMap.getOrDefault(value, UNKNOWN); + } + + public static UploadResult fromOperationResult(RemoteOperationResult result) { + return switch (result.getCode()) { + case OK -> UPLOADED; + case NO_NETWORK_CONNECTION, HOST_NOT_AVAILABLE, TIMEOUT, WRONG_CONNECTION, INCORRECT_ADDRESS, SSL_ERROR -> + NETWORK_CONNECTION; + case ACCOUNT_EXCEPTION, UNAUTHORIZED -> CREDENTIAL_ERROR; + case FILE_NOT_FOUND -> FOLDER_ERROR; + case LOCAL_FILE_NOT_FOUND -> FILE_NOT_FOUND; + case CONFLICT -> CONFLICT_ERROR; + case LOCAL_STORAGE_NOT_COPIED -> LOCAL_STORAGE_NOT_COPIED; + case LOCAL_STORAGE_FULL -> LOCAL_STORAGE_FULL; + case OLD_ANDROID_API -> OLD_ANDROID_API; + case SYNC_CONFLICT -> SYNC_CONFLICT; + case FORBIDDEN -> PRIVILEGES_ERROR; + case CANCELLED -> CANCELLED; + case DELAYED_FOR_WIFI -> DELAYED_FOR_WIFI; + case DELAYED_FOR_CHARGING -> DELAYED_FOR_CHARGING; + case DELAYED_IN_POWER_SAVE_MODE -> DELAYED_IN_POWER_SAVE_MODE; + case MAINTENANCE_MODE -> MAINTENANCE_MODE; + case SSL_RECOVERABLE_PEER_UNVERIFIED -> SSL_RECOVERABLE_PEER_UNVERIFIED; + case UNKNOWN_ERROR -> { + if (result.getException() instanceof java.io.FileNotFoundException) { + yield FILE_ERROR; + } + yield UNKNOWN; + } + case LOCK_FAILED -> LOCK_FAILED; + case VIRUS_DETECTED -> VIRUS_DETECTED; + case CANNOT_CREATE_FILE -> CANNOT_CREATE_FILE; + case QUOTA_EXCEEDED -> QUOTA_EXCEEDED; + default -> UNKNOWN; + }; + } +} diff --git a/app/src/main/java/com/owncloud/android/features/FeatureItem.java b/app/src/main/java/com/owncloud/android/features/FeatureItem.java new file mode 100644 index 000000000000..a389e9c8e236 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/features/FeatureItem.java @@ -0,0 +1,95 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.features; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.owncloud.android.R; + +public class FeatureItem implements Parcelable { + private static final int DO_NOT_SHOW = -1; + private int image; + private int titleText; + private int contentText; + private boolean contentCentered; + private boolean bulletList; + + public FeatureItem(int image, int titleText, int contentText, boolean contentCentered, boolean bulletList) { + this.image = image; + this.titleText = titleText; + this.contentText = contentText; + this.contentCentered = contentCentered; + this.bulletList = bulletList; + } + + public boolean shouldShowImage() { + return image != DO_NOT_SHOW; + } + + public boolean shouldShowTitleText() { + return titleText != DO_NOT_SHOW && titleText != R.string.empty; + } + + public boolean shouldShowContentText() { + return contentText != DO_NOT_SHOW && contentText != R.string.empty; + } + + public boolean shouldContentCentered() { + return contentCentered; + } + + public boolean shouldShowBulletPointList() { + return bulletList; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(image); + dest.writeInt(titleText); + dest.writeInt(contentText); + dest.writeByte((byte) (contentCentered ? 1 : 0)); + dest.writeByte((byte) (bulletList ? 1 : 0)); + } + + private FeatureItem(Parcel p) { + image = p.readInt(); + titleText = p.readInt(); + contentText = p.readInt(); + contentCentered = p.readByte() == 1; + bulletList = p.readByte() == 1; + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Object createFromParcel(Parcel source) { + return new FeatureItem(source); + } + + @Override + public Object[] newArray(int size) { + return new FeatureItem[size]; + } + }; + + public int getImage() { + return this.image; + } + + public int getTitleText() { + return this.titleText; + } + + public int getContentText() { + return this.contentText; + } +} diff --git a/app/src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java b/app/src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java new file mode 100644 index 000000000000..8eb62f17a9c9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java @@ -0,0 +1,80 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-FileCopyrightText: 2012 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.files; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.core.Clock; +import com.nextcloud.client.device.PowerManagementService; +import com.nextcloud.client.jobs.BackgroundJobManager; +import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.network.WalledCheckCache; +import com.nextcloud.client.preferences.AppPreferences; +import com.owncloud.android.MainApp; +import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import javax.inject.Inject; + +import dagger.android.AndroidInjection; + +/** + * App-registered receiver catching the broadcast intent reporting that the system was + * just boot up. + */ +public class BootupBroadcastReceiver extends BroadcastReceiver { + + private static final String TAG = BootupBroadcastReceiver.class.getSimpleName(); + + @Inject AppPreferences preferences; + @Inject UserAccountManager accountManager; + @Inject UploadsStorageManager uploadsStorageManager; + @Inject ConnectivityService connectivityService; + @Inject PowerManagementService powerManagementService; + @Inject BackgroundJobManager backgroundJobManager; + @Inject Clock clock; + @Inject ViewThemeUtils viewThemeUtils; + @Inject WalledCheckCache walledCheckCache; + + /** + * Receives broadcast intent reporting that the system was just boot up. * + * + * @param context The context where the receiver is running. + * @param intent The intent received. + */ + @Override + public void onReceive(Context context, Intent intent) { + AndroidInjection.inject(this, context); + + if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + MainApp.initSyncOperations(context, + preferences, + uploadsStorageManager, + accountManager, + connectivityService, + powerManagementService, + backgroundJobManager, + clock, + viewThemeUtils, + walledCheckCache); + Log_OC.d(TAG, "scheduleContentObserverJob, called"); + backgroundJobManager.scheduleContentObserverJob(); + MainApp.initContactsBackup(accountManager, backgroundJobManager); + } else { + Log_OC.d(TAG, "Getting wrong intent: " + intent.getAction()); + } + } +} diff --git a/app/src/main/java/com/owncloud/android/files/CreateFileFromTemplateOperation.java b/app/src/main/java/com/owncloud/android/files/CreateFileFromTemplateOperation.java new file mode 100644 index 000000000000..5e146a89ccd9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/files/CreateFileFromTemplateOperation.java @@ -0,0 +1,82 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.files; + +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.httpclient.methods.Utf8PostMethod; +import org.json.JSONObject; + +import java.util.ArrayList; + +public class CreateFileFromTemplateOperation extends RemoteOperation { + private static final String TAG = CreateFileFromTemplateOperation.class.getSimpleName(); + private static final int SYNC_READ_TIMEOUT = 40000; + private static final int SYNC_CONNECTION_TIMEOUT = 5000; + private static final String NEW_FROM_TEMPLATE_URL = "/ocs/v2.php/apps/richdocuments/api/v1/templates/new"; + + private String path; + private long templateId; + + // JSON node names + private static final String NODE_OCS = "ocs"; + private static final String NODE_DATA = "data"; + private static final String JSON_FORMAT = "?format=json"; + + public CreateFileFromTemplateOperation(String path, long templateId) { + this.path = path; + this.templateId = templateId; + } + + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + Utf8PostMethod postMethod = null; + + try { + + postMethod = new Utf8PostMethod(client.getBaseUri() + NEW_FROM_TEMPLATE_URL + JSON_FORMAT); + postMethod.setParameter("path", path); + postMethod.setParameter("template", String.valueOf(templateId)); + + // remote request + postMethod.addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE); + + int status = client.executeMethod(postMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT); + + if (status == HttpStatus.SC_OK) { + String response = postMethod.getResponseBodyAsString(); + + // Parse the response + JSONObject respJSON = new JSONObject(response); + String url = respJSON.getJSONObject(NODE_OCS).getJSONObject(NODE_DATA).getString("url"); + + ArrayList templateArray = new ArrayList<>(); + templateArray.add(url); + + result = new RemoteOperationResult(true, postMethod); + result.setData(templateArray); + } else { + result = new RemoteOperationResult(false, postMethod); + client.exhaustResponse(postMethod.getResponseBodyAsStream()); + } + } catch (Exception e) { + result = new RemoteOperationResult(e); + Log_OC.e(TAG, "Create file from template " + templateId + " failed: " + result.getLogMessage(), + result.getException()); + } finally { + if (postMethod != null) { + postMethod.releaseConnection(); + } + } + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/files/FetchTemplateOperation.java b/app/src/main/java/com/owncloud/android/files/FetchTemplateOperation.java new file mode 100644 index 000000000000..e16fafa6f562 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/files/FetchTemplateOperation.java @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.files; + +import com.owncloud.android.datamodel.Template; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.dialog.ChooseRichDocumentsTemplateDialogFragment; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.httpclient.methods.GetMethod; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Locale; + +public class FetchTemplateOperation extends RemoteOperation { + private static final String TAG = FetchTemplateOperation.class.getSimpleName(); + private static final int SYNC_READ_TIMEOUT = 40000; + private static final int SYNC_CONNECTION_TIMEOUT = 5000; + private static final String TEMPLATE_URL = "/ocs/v2.php/apps/richdocuments/api/v1/templates/"; + + private ChooseRichDocumentsTemplateDialogFragment.Type type; + + // JSON node names + private static final String NODE_OCS = "ocs"; + private static final String NODE_DATA = "data"; + private static final String JSON_FORMAT = "?format=json"; + + public FetchTemplateOperation(ChooseRichDocumentsTemplateDialogFragment.Type type) { + this.type = type; + } + + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + GetMethod getMethod = null; + + try { + + getMethod = new GetMethod(client.getBaseUri() + TEMPLATE_URL + type.toString().toLowerCase(Locale.ENGLISH) + + JSON_FORMAT); + + // remote request + getMethod.addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE); + + int status = client.executeMethod(getMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT); + + if (status == HttpStatus.SC_OK) { + String response = getMethod.getResponseBodyAsString(); + + // Parse the response + JSONObject respJSON = new JSONObject(response); + JSONArray templates = respJSON.getJSONObject(NODE_OCS).getJSONArray(NODE_DATA); + + ArrayList templateArray = new ArrayList<>(); + + for (int i = 0; i < templates.length(); i++) { + JSONObject templateObject = templates.getJSONObject(i); + + templateArray.add(new Template(templateObject.getLong("id"), + templateObject.getString("name"), + templateObject.optString("preview"), + Template.Type.parse(templateObject.getString("type") + .toUpperCase(Locale.ROOT)), + templateObject.getString("extension"))); + } + + result = new RemoteOperationResult(true, getMethod); + result.setData(templateArray); + } else { + result = new RemoteOperationResult(false, getMethod); + client.exhaustResponse(getMethod.getResponseBodyAsStream()); + } + } catch (Exception e) { + result = new RemoteOperationResult(e); + Log_OC.e(TAG, "Get templates for typ " + type + " failed: " + result.getLogMessage(), + result.getException()); + } finally { + if (getMethod != null) { + getMethod.releaseConnection(); + } + } + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java new file mode 100644 index 000000000000..6665598c0a4b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java @@ -0,0 +1,608 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2019-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Álvaro Brey Vilas + * SPDX-FileCopyrightText: 2020 Andy Scherzinger + * SPDX-FileCopyrightText: 2018 Mario Danic + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2014-2016 David A. Velasco + * SPDX-FileCopyrightText: 2012 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.files; + +import android.accounts.AccountManager; +import android.content.Context; +import android.view.Menu; + +import com.nextcloud.android.files.FileLockingHelper; +import com.nextcloud.client.account.User; +import com.nextcloud.client.editimage.EditImageActivity; +import com.nextcloud.client.jobs.download.FileDownloadHelper; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.ui.fileactions.FileAction; +import com.nextcloud.utils.EditorUtils; +import com.nextcloud.utils.mdm.MDMConfig; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.services.OperationsService.OperationsServiceBinder; +import com.owncloud.android.ui.activity.ComponentsGetter; +import com.owncloud.android.ui.helpers.FileOperationsHelper; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.NextcloudServer; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; + +import javax.inject.Inject; + +import androidx.annotation.IdRes; +import androidx.core.content.pm.ShortcutManagerCompat; + +/** + * Filters out the file actions available in a given {@link Menu} for a given {@link OCFile} + * according to the current state of the latest. + */ +public class FileMenuFilter { + + private static final int SINGLE_SELECT_ITEMS = 1; + private static final int EMPTY_FILE_LENGTH = 0; + public static final String SEND_OFF = "off"; + + private final int numberOfAllFiles; + private final Collection files; + private final ComponentsGetter componentsGetter; + private final Context context; + private final boolean overflowMenu; + private final User user; + private final String userId; + private final FileDataStorageManager storageManager; + private final EditorUtils editorUtils; + + + public static class Factory { + private final FileDataStorageManager storageManager; + private final Context context; + private final EditorUtils editorUtils; + + @Inject + public Factory(final FileDataStorageManager storageManager, final Context context, final EditorUtils editorUtils) { + this.storageManager = storageManager; + this.context = context; + this.editorUtils = editorUtils; + } + + /** + * @param numberOfAllFiles Number of all displayed files + * @param files Collection of {@link OCFile} file targets of the action to filter in the {@link Menu}. + * @param componentsGetter Accessor to app components, needed to access synchronization services + * @param overflowMenu true if the overflow menu items are being filtered + * @param user currently active user + */ + public FileMenuFilter newInstance(final int numberOfAllFiles, final Collection files, final ComponentsGetter componentsGetter, boolean overflowMenu, User user) { + return new FileMenuFilter(storageManager, editorUtils, numberOfAllFiles, files, componentsGetter, context, overflowMenu, user); + } + + /** + * @param file {@link OCFile} file target + * @param componentsGetter Accessor to app components, needed to access synchronization services + * @param overflowMenu true if the overflow menu items are being filtered + * @param user currently active user + */ + public FileMenuFilter newInstance(final OCFile file, final ComponentsGetter componentsGetter, boolean overflowMenu, User user) { + return newInstance(1, Collections.singletonList(file), componentsGetter, overflowMenu, user); + } + } + + + private FileMenuFilter(FileDataStorageManager storageManager, EditorUtils editorUtils, int numberOfAllFiles, + Collection files, + ComponentsGetter componentsGetter, + Context context, + boolean overflowMenu, + User user + ) { + this.storageManager = storageManager; + this.editorUtils = editorUtils; + this.numberOfAllFiles = numberOfAllFiles; + this.files = files; + this.componentsGetter = componentsGetter; + this.context = context; + this.overflowMenu = overflowMenu; + this.user = user; + userId = AccountManager + .get(context) + .getUserData(this.user.toPlatformAccount(), + com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID); + } + + /** + * List of actions to remove given the parameters supplied in the constructor + */ + @IdRes + public List getToHide(final boolean inSingleFileFragment){ + if(files != null && ! files.isEmpty()){ + return filter(inSingleFileFragment); + } + return null; + } + + + /** + * Decides what actions must be shown and hidden implementing the different rule sets. + * + * @param inSingleFileFragment True if this is not listing, but single file fragment, like preview or details. + */ + private List filter(boolean inSingleFileFragment) { + boolean synchronizing = anyFileSynchronizing(); + OCCapability capability = storageManager.getCapability(user.getAccountName()); + boolean endToEndEncryptionEnabled = capability.getEndToEndEncryption().isTrue(); + boolean fileLockingEnabled = capability.getFilesLockingVersion() != null; + + @IdRes final List toHide = new ArrayList<>(); + + filterEdit(toHide, capability); + filterDownload(toHide, synchronizing); + filterExport(toHide); + filterRename(toHide, synchronizing); + filterMoveOrCopy(toHide, synchronizing); + filterRemove(toHide, synchronizing); + filterSelectAll(toHide, inSingleFileFragment); + filterDeselectAll(toHide, inSingleFileFragment); + filterOpenWith(toHide, synchronizing); + filterCancelSync(toHide, synchronizing); + filterSync(toHide, synchronizing); + filterShareFile(toHide, capability); + filterSendFiles(toHide, inSingleFileFragment); + filterDetails(toHide); + filterFavorite(toHide, synchronizing); + filterUnfavorite(toHide, synchronizing); + filterEncrypt(toHide, endToEndEncryptionEnabled); + filterUnsetEncrypted(toHide, endToEndEncryptionEnabled); + filterSetPictureAs(toHide); + filterStream(toHide); + filterLock(toHide, fileLockingEnabled); + filterUnlock(toHide, fileLockingEnabled); + filterPinToHome(toHide); + filterRetry(toHide); + filterPermissionActions(toHide); + + return toHide; + } + + private void filterPermissionActions(List toHide) { + final var actionsToHide = FileAction.Companion.getActionsToHide(new HashSet<>(files)); + toHide.addAll(actionsToHide); + } + + + private void filterShareFile(List toHide, OCCapability capability) { + if (!isSingleSelection() || containsEncryptedFile() || hasEncryptedParent() || + (!isShareViaLinkAllowed() && !isShareWithUsersAllowed()) || + !isShareApiEnabled(capability) || !files.iterator().next().canReshare()) { + toHide.add(R.id.action_send_share_file); + } + } + + private void filterSendFiles(List toHide, boolean inSingleFileFragment) { + boolean sendFilesNotSupported = context != null && !MDMConfig.INSTANCE.sendFilesSupport(context); + boolean hasEncryptedFile = containsEncryptedFile(); + boolean isSingleSelection = isSingleSelection(); + boolean allFilesNotDown = !allFileDown(); + + if (sendFilesNotSupported) { + toHide.add(R.id.action_send_file); + return; + } + + if (overflowMenu || hasEncryptedFile) { + toHide.add(R.id.action_send_file); + return; + } + + if (!inSingleFileFragment && (isSingleSelection || allFilesNotDown)) { + toHide.add(R.id.action_send_file); + } else if (!toHide.contains(R.id.action_send_share_file)) { + toHide.add(R.id.action_send_file); + } + } + + private void filterDetails(Collection toHide) { + if (!isSingleSelection()) { + toHide.add(R.id.action_see_details); + } + } + + private void filterFavorite(List toHide, boolean synchronizing) { + if (files.isEmpty() || synchronizing || allFavorites()) { + toHide.add(R.id.action_favorite); + } + } + + private void filterUnfavorite(List toHide, boolean synchronizing) { + if (files.isEmpty() || synchronizing || allNotFavorites()) { + toHide.add(R.id.action_unset_favorite); + } + } + + private void filterLock(List toHide, boolean fileLockingEnabled) { + if (files.isEmpty() || + !isSingleSelection() || + !fileLockingEnabled || + containsEncryptedFile() || + containsEncryptedFolder()) { + toHide.add(R.id.action_lock_file); + } else { + OCFile file = files.iterator().next(); + if (file.isLocked() || file.isFolder()) { + toHide.add(R.id.action_lock_file); + } + } + } + + private void filterUnlock(List toHide, boolean fileLockingEnabled) { + if (files.isEmpty() || !isSingleSelection() || !fileLockingEnabled) { + toHide.add(R.id.action_unlock_file); + } else { + OCFile file = files.iterator().next(); + if (!FileLockingHelper.canUserUnlockFile(userId, file)) { + toHide.add(R.id.action_unlock_file); + } + } + } + + private void filterEncrypt(List toHide, boolean endToEndEncryptionEnabled) { + if (files.isEmpty() || !isSingleSelection() || isSingleFile() || isEncryptedFolder() || isGroupFolder() + || !endToEndEncryptionEnabled || !isEmptyFolder() || isShared()) { + toHide.add(R.id.action_encrypted); + } + } + + private void filterUnsetEncrypted(List toHide, boolean endToEndEncryptionEnabled) { + if (!endToEndEncryptionEnabled || files.isEmpty() || !isSingleSelection() || isSingleFile() || !isEncryptedFolder() || hasEncryptedParent() + || !isEmptyFolder() || !FileOperationsHelper.isEndToEndEncryptionSetup(context, user)) { + toHide.add(R.id.action_unset_encrypted); + } + } + + private void filterSetPictureAs(List toHide) { + if (!isSingleImage() || MimeTypeUtil.isSVG(files.iterator().next())) { + toHide.add(R.id.action_set_as_wallpaper); + } + } + + private void filterPinToHome(List toHide) { + if (!isSingleSelection() || !ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { + toHide.add(R.id.action_pin_to_homescreen); + } + } + + private void filterRetry(List toHide) { + if (!files.iterator().next().isOfflineOperation()) { + toHide.add(R.id.action_retry); + } + } + + private void filterEdit( + List toHide, + OCCapability capability + ) { + if (files.iterator().next().isEncrypted()) { + toHide.add(R.id.action_edit); + return; + } + + String mimeType = files.iterator().next().getMimeType(); + + if (!isRichDocumentEditingSupported(capability, mimeType) && !editorUtils.isEditorAvailable(user, mimeType) && + !(isSingleImage() && EditImageActivity.Companion.canBePreviewed(files.iterator().next()))) { + toHide.add(R.id.action_edit); + } + } + + /** + * This will be replaced by unified editor and can be removed once EOL of corresponding server version. + */ + @NextcloudServer(max = 18) + private boolean isRichDocumentEditingSupported(OCCapability capability, String mimeType) { + return isSingleFile() && + (capability.getRichDocumentsMimeTypeList().contains(mimeType) || + capability.getRichDocumentsOptionalMimeTypeList().contains(mimeType)) && + capability.getRichDocumentsDirectEditing().isTrue(); + } + + private void filterSync(List toHide, boolean synchronizing) { + if (files.isEmpty() || (!anyFileDown() && !containsFolder()) || synchronizing || containsEncryptedFile() + || containsEncryptedFolder()) { + toHide.add(R.id.action_sync_file); + } + } + + private void filterCancelSync(List toHide, boolean synchronizing) { + if (files.isEmpty() || !synchronizing) { + toHide.add(R.id.action_cancel_sync); + } + } + + private void filterOpenWith(Collection toHide, boolean synchronizing) { + if (!isSingleFile() || !anyFileDown() || synchronizing) { + toHide.add(R.id.action_open_file_with); + } + } + + private void filterDeselectAll(List toHide, boolean inSingleFileFragment) { + if (inSingleFileFragment) { + // Always hide in single file fragments + toHide.add(R.id.action_deselect_all_action_menu); + } else { + // Show only if at least one item is selected. + if (files.isEmpty() || overflowMenu) { + toHide.add(R.id.action_deselect_all_action_menu); + } + } + } + + private void filterSelectAll(List toHide, boolean inSingleFileFragment) { + if (!inSingleFileFragment) { + // Show only if at least one item isn't selected. + if (files.size() >= numberOfAllFiles || overflowMenu) { + toHide.add(R.id.action_select_all_action_menu); + } + } else { + // Always hide in single file fragments + toHide.add(R.id.action_select_all_action_menu); + } + } + + private void filterRemove(List toHide, boolean synchronizing) { + if (files.isEmpty() || synchronizing || containsLockedFile() + || containsEncryptedFolder() || isFolderAndContainsEncryptedFile()) { + toHide.add(R.id.action_remove_file); + } + } + + private void filterMoveOrCopy(List toHide, boolean synchronizing) { + if (files.isEmpty() || synchronizing || containsEncryptedFile() || containsEncryptedFolder() || containsLockedFile()) { + toHide.add(R.id.action_move_or_copy); + } + } + + private void filterRename(Collection toHide, boolean synchronizing) { + if (!isSingleSelection() || synchronizing || containsEncryptedFile() || containsEncryptedFolder() || containsLockedFile()) { + toHide.add(R.id.action_rename_file); + } + } + + private void filterDownload(List toHide, boolean synchronizing) { + if (files.isEmpty() || containsFolder() || anyFileDown() || synchronizing) { + toHide.add(R.id.action_download_file); + } + } + + private void filterExport(List toHide) { + if (files.isEmpty() || containsFolder()) { + toHide.add(R.id.action_export_file); + } + } + + private void filterStream(List toHide) { + if (files.isEmpty() || !isSingleFile() || !isSingleMedia() || containsEncryptedFile()) { + toHide.add(R.id.action_stream_media); + } + } + + private boolean anyFileSynchronizing() { + boolean synchronizing = false; + if (componentsGetter != null && !files.isEmpty() && user != null) { + OperationsServiceBinder opsBinder = componentsGetter.getOperationsServiceBinder(); + synchronizing = anyFileSynchronizing(opsBinder) || // comparing local and remote + anyFileDownloading() || + anyFileUploading(); + } + return synchronizing; + } + + private boolean anyFileSynchronizing(OperationsServiceBinder opsBinder) { + boolean synchronizing = false; + if (opsBinder != null) { + for (Iterator iterator = files.iterator(); !synchronizing && iterator.hasNext(); ) { + synchronizing = opsBinder.isSynchronizing(user, iterator.next()); + } + } + return synchronizing; + } + + private boolean anyFileDownloading() { + final var fileDownloadHelper = FileDownloadHelper.Companion.instance(); + + for (OCFile file : files) { + if (fileDownloadHelper.isDownloading(user, file)) { + return true; + } + } + + return false; + } + + private boolean anyFileUploading() { + for (OCFile file : files) { + if (FileUploadHelper.Companion.instance().isUploading(file.getRemotePath(), user.getAccountName())) { + return true; + } + } + return false; + } + + private boolean isShareApiEnabled(OCCapability capability) { + return capability != null && + (capability.getFilesSharingApiEnabled().isTrue() || + capability.getFilesSharingApiEnabled().isUnknown() + ); + } + + private boolean isShareWithUsersAllowed() { + return context != null && MDMConfig.INSTANCE.shareViaUser(context); + } + + private boolean isShareViaLinkAllowed() { + return context != null && MDMConfig.INSTANCE.shareViaLink(context); + } + + private boolean isSingleSelection() { + return files.size() == SINGLE_SELECT_ITEMS; + } + + private boolean isSingleFile() { + return isSingleSelection() && !files.iterator().next().isFolder(); + } + + private boolean isEncryptedFolder() { + if (isSingleSelection()) { + OCFile file = files.iterator().next(); + + return file.isFolder() && file.isEncrypted(); + } else { + return false; + } + } + + private boolean isEmptyFolder() { + if (isSingleSelection()) { + OCFile file = files.iterator().next(); + + boolean noChildren = storageManager + .getFolderContent(file, false).size() == EMPTY_FILE_LENGTH; + + return file.isFolder() && file.getFileLength() == EMPTY_FILE_LENGTH && noChildren; + } else { + return false; + } + } + + private boolean isGroupFolder() { + return files.iterator().next().mounted(); + } + + private boolean hasEncryptedParent() { + OCFile folder = files.iterator().next(); + OCFile parent = storageManager.getFileById(folder.getParentId()); + + return parent != null && parent.isEncrypted(); + } + + private boolean isSingleImage() { + return isSingleSelection() && MimeTypeUtil.isImage(files.iterator().next()); + } + + private boolean isSingleMedia() { + OCFile file = files.iterator().next(); + return isSingleSelection() && (MimeTypeUtil.isVideo(file) || MimeTypeUtil.isAudio(file)); + } + + private boolean isFolderAndContainsEncryptedFile() { + for (OCFile file : files) { + if (!file.isFolder()) { + continue; + } + if (file.isFolder()) { + List children = storageManager.getFolderContent(file, false); + for (OCFile child : children) { + if (child.isEncrypted()) { + return true; + } + } + } + } + return false; + } + + + private boolean containsEncryptedFile() { + for (OCFile file : files) { + if (!file.isFolder() && file.isEncrypted()) { + return true; + } + } + return false; + } + + private boolean containsLockedFile() { + for (OCFile file : files) { + if (file.isLocked()) { + return true; + } + } + return false; + } + + private boolean containsEncryptedFolder() { + for (OCFile file : files) { + if (file.isFolder() && file.isEncrypted()) { + return true; + } + } + return false; + } + + private boolean containsFolder() { + for (OCFile file : files) { + if (file.isFolder()) { + return true; + } + } + return false; + } + + private boolean anyFileDown() { + for (OCFile file : files) { + if (file.isDown()) { + return true; + } + } + return false; + } + + private boolean allFileDown() { + for (OCFile file: files) { + if(!file.isDown()) { + return false; + } + } + return true; + } + + private boolean allFavorites() { + for (OCFile file : files) { + if (!file.isFavorite()) { + return false; + } + } + return true; + } + + private boolean allNotFavorites() { + for (OCFile file : files) { + if (file.isFavorite()) { + return false; + } + } + return true; + } + + private boolean isShared() { + for (OCFile file : files) { + if (file.isSharedWithMe() || file.isSharedViaLink() || file.isSharedWithSharee()) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.java b/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.java new file mode 100644 index 000000000000..6d12aed0aaa1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.java @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.files; + +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.httpclient.methods.Utf8PostMethod; +import org.json.JSONObject; + +import java.util.ArrayList; + +public class StreamMediaFileOperation extends RemoteOperation { + private static final String TAG = StreamMediaFileOperation.class.getSimpleName(); + private static final int SYNC_READ_TIMEOUT = 40000; + private static final int SYNC_CONNECTION_TIMEOUT = 5000; + private static final String STREAM_MEDIA_URL = "/ocs/v2.php/apps/dav/api/v1/direct"; + + private final long fileID; + + // JSON node names + private static final String NODE_OCS = "ocs"; + private static final String NODE_DATA = "data"; + private static final String NODE_URL = "url"; + private static final String JSON_FORMAT = "?format=json"; + + public StreamMediaFileOperation(long fileID) { + this.fileID = fileID; + } + + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + Utf8PostMethod postMethod = null; + + try { + postMethod = new Utf8PostMethod(client.getBaseUri() + STREAM_MEDIA_URL + JSON_FORMAT); + postMethod.setParameter("fileId", String.valueOf(fileID)); + + // remote request + postMethod.addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE); + + int status = client.executeMethod(postMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT); + + if (status == HttpStatus.SC_OK) { + String response = postMethod.getResponseBodyAsString(); + + // Parse the response + JSONObject respJSON = new JSONObject(response); + String url = respJSON.getJSONObject(NODE_OCS).getJSONObject(NODE_DATA).getString(NODE_URL); + + result = new RemoteOperationResult(true, postMethod); + ArrayList urlArray = new ArrayList<>(); + urlArray.add(url); + result.setData(urlArray); + } else { + result = new RemoteOperationResult(false, postMethod); + client.exhaustResponse(postMethod.getResponseBodyAsStream()); + } + } catch (Exception e) { + result = new RemoteOperationResult(e); + Log_OC.e(TAG, "Get stream url for file with id " + fileID + " failed: " + result.getLogMessage(), + result.getException()); + } finally { + if (postMethod != null) { + postMethod.releaseConnection(); + } + } + return result; + } +} diff --git a/src/main/java/com/owncloud/android/files/services/IndexedForest.java b/app/src/main/java/com/owncloud/android/files/services/IndexedForest.java similarity index 76% rename from src/main/java/com/owncloud/android/files/services/IndexedForest.java rename to app/src/main/java/com/owncloud/android/files/services/IndexedForest.java index 9a4ee80254f8..b19e5eb50bce 100644 --- a/src/main/java/com/owncloud/android/files/services/IndexedForest.java +++ b/app/src/main/java/com/owncloud/android/files/services/IndexedForest.java @@ -1,23 +1,12 @@ -/** - * ownCloud Android client application - * - * @author David A. Velasco - * Copyright (C) 2016 ownCloud Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . +/* + * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2017-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ - package com.owncloud.android.files.services; import android.util.Pair; @@ -34,21 +23,23 @@ /** * Helper structure to keep the trees of folders containing any file downloading or synchronizing. - * * A map provides the indexation based in hashing. - * * A tree is created per account. */ public class IndexedForest { private ConcurrentMap> mMap = new ConcurrentHashMap<>(); + public ConcurrentMap> getAll() { + return mMap; + } + @SuppressWarnings("PMD.ShortClassName") - private class Node { - private String mKey = null; - private Node mParent = null; + public class Node { + private String mKey; + private Node mParent; private Set> mChildren = new HashSet<>(); // TODO be careful with hash() - private V mPayload = null; + private V mPayload; // payload is optional public Node(String key, V payload) { @@ -101,7 +92,7 @@ public void clearPayload() { public /* synchronized */ Pair putIfAbsent(String accountName, String remotePath, V value) { String targetKey = buildKey(accountName, remotePath); - Node valuedNode = new Node(targetKey, value); + Node valuedNode = new Node<>(targetKey, value); Node previousValue = mMap.putIfAbsent( targetKey, valuedNode @@ -113,20 +104,20 @@ public void clearPayload() { } else { // value really added String currentPath = remotePath; - String parentPath = null; - String parentKey = null; + String parentPath; + String parentKey; Node currentNode = valuedNode; Node parentNode = null; boolean linked = false; while (!OCFile.ROOT_PATH.equals(currentPath) && !linked) { parentPath = new File(currentPath).getParent(); - if (!parentPath.endsWith(OCFile.PATH_SEPARATOR)) { + if (parentPath != null && !parentPath.endsWith(OCFile.PATH_SEPARATOR)) { parentPath += OCFile.PATH_SEPARATOR; } parentKey = buildKey(accountName, parentPath); parentNode = mMap.get(parentKey); if (parentNode == null) { - parentNode = new Node(parentKey, null); + parentNode = new Node<>(parentKey, null); parentNode.addChild(currentNode); mMap.put(parentKey, parentNode); } else { @@ -142,7 +133,7 @@ public void clearPayload() { linkedTo = parentNode.getKey().substring(accountName.length()); } - return new Pair(targetKey, linkedTo); + return new Pair<>(targetKey, linkedTo); } } @@ -156,7 +147,7 @@ public Pair removePayload(String accountName, String remotePath) { return remove(accountName, remotePath); } } - return new Pair(null, null); + return new Pair<>(null, null); } @@ -186,17 +177,14 @@ public Pair removePayload(String accountName, String remotePath) { unlinkedFrom = parent.getKey().substring(accountName.length()); } - return new Pair(firstRemoved.getPayload(), unlinkedFrom); + return new Pair<>(firstRemoved.getPayload(), unlinkedFrom); } - return new Pair(null, null); + return new Pair<>(null, null); } private void removeDescendants(Node removed) { - Iterator> childrenIt = removed.getChildren().iterator(); - Node child = null; - while (childrenIt.hasNext()) { - child = childrenIt.next(); + for (Node child : removed.getChildren()) { mMap.remove(child.getKey()); removeDescendants(child); } @@ -227,14 +215,7 @@ public V get(String accountName, String remotePath) { * @param accountName */ public void remove(String accountName){ - Iterator it = mMap.keySet().iterator(); - while (it.hasNext()) { - String key = it.next(); - Log_OC.d("IndexedForest", "Number of pending downloads= " + mMap.size()); - if (key.startsWith(accountName)) { - mMap.remove(key); - } - } + mMap.keySet().removeIf(key -> key.startsWith(accountName)); } /** @@ -246,5 +227,4 @@ public void remove(String accountName){ private String buildKey(String accountName, String remotePath) { return accountName + remotePath; } - } diff --git a/app/src/main/java/com/owncloud/android/files/services/NameCollisionPolicy.java b/app/src/main/java/com/owncloud/android/files/services/NameCollisionPolicy.java new file mode 100644 index 000000000000..071a1c05cff9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/files/services/NameCollisionPolicy.java @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.files.services; + +/** + * Defines how to handle file name collisions during uploads. + * + *

Important: Enum ordinals are stored directly in the database. + * Do not change their order or remove constants to avoid breaking + * compatibility with old data.

+ * + *

Database value mapping:

+ *
    + *
  • 0 → {@link #RENAME} (old forceOverwrite = false)
  • + *
  • 1 → {@link #OVERWRITE} (old forceOverwrite = true)
  • + *
  • 2 → {@link #SKIP}
  • + *
  • 3 → {@link #ASK_USER}
  • + *
+ */ +public enum NameCollisionPolicy { + RENAME, + OVERWRITE, + SKIP, + ASK_USER; + + public static final NameCollisionPolicy DEFAULT = RENAME; + + public static NameCollisionPolicy deserialize(int ordinal) { + NameCollisionPolicy[] values = NameCollisionPolicy.values(); + return ordinal >= 0 && ordinal < values.length ? values[ordinal] : DEFAULT; + } + + public int serialize() { + return this.ordinal(); + } +} diff --git a/.gitmodules b/app/src/main/java/com/owncloud/android/jobs/OfflineSyncJob.java similarity index 100% rename from .gitmodules rename to app/src/main/java/com/owncloud/android/jobs/OfflineSyncJob.java diff --git a/app/src/main/java/com/owncloud/android/media/MediaControlView.kt b/app/src/main/java/com/owncloud/android/media/MediaControlView.kt new file mode 100644 index 000000000000..217c1c153501 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/media/MediaControlView.kt @@ -0,0 +1,355 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey Vilas + * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2013 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND AGPL-3.0-or-later + */ +package com.owncloud.android.media + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.core.content.ContextCompat +import androidx.media3.common.Player +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.MediaControlBinding +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.util.Formatter +import java.util.Locale +import javax.inject.Inject + +/** + * View containing controls for a MediaPlayer. + * + * + * Holds buttons "play / pause", "rewind", "fast forward" and a progress slider. + * + * + * It synchronizes itself with the state of the MediaPlayer. + */ +class MediaControlView(context: Context, attrs: AttributeSet?) : + LinearLayout(context, attrs), + View.OnClickListener, + OnSeekBarChangeListener { + + private var playerControl: Player? = null + private var binding: MediaControlBinding + private var isDragging = false + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + public override fun onFinishInflate() { + super.onFinishInflate() + } + + @Suppress("MagicNumber") + fun setMediaPlayer(player: Player?) { + playerControl = player + handler.sendEmptyMessage(SHOW_PROGRESS) + + handler.postDelayed({ + updatePausePlay() + setProgress() + }, 100) + } + + @Suppress("MagicNumber") + private fun initControllerView() { + binding.playBtn.requestFocus() + + binding.playBtn.setOnClickListener(this) + binding.forwardBtn.setOnClickListener(this) + binding.rewindBtn.setOnClickListener(this) + + binding.progressBar.run { + viewThemeUtils.platform.themeHorizontalSeekBar(this) + setMax(1000) + } + + binding.progressBar.setOnSeekBarChangeListener(this) + + viewThemeUtils.material.run { + colorMaterialButtonPrimaryTonal(binding.rewindBtn) + colorMaterialButtonPrimaryTonal(binding.playBtn) + colorMaterialButtonPrimaryTonal(binding.forwardBtn) + } + } + + /** + * Disable pause or seek buttons if the stream cannot be paused or seeked. + * This requires the control interface to be a MediaPlayerControlExt + */ + private fun disableUnsupportedButtons() { + try { + if (playerControl?.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)?.not() == true) { + binding.playBtn.isEnabled = false + } + + if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_BACK)?.not() == true) { + binding.rewindBtn.isEnabled = false + } + if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_FORWARD)?.not() == true) { + binding.forwardBtn.isEnabled = false + } + } catch (ex: IncompatibleClassChangeError) { + // We were given an old version of the interface, that doesn't have + // the canPause/canSeekXYZ methods. This is OK, it just means we + // assume the media can be paused and seeked, and so we don't disable + // the buttons. + Log_OC.i(TAG, "Old media interface detected") + } + } + + @Suppress("MagicNumber") + private val handler: Handler = object : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + if (msg.what == SHOW_PROGRESS) { + updatePausePlay() + val pos = setProgress() + + if (!isDragging) { + sendMessageDelayed(obtainMessage(SHOW_PROGRESS), (1000 - pos % 1000)) + } + } + } + } + + init { + MainApp.getAppComponent().inject(this) + + val inflate = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + binding = MediaControlBinding.inflate(inflate, this, true) + initControllerView() + isFocusable = true + setFocusableInTouchMode(true) + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS) + requestFocus() + } + + @Suppress("MagicNumber") + private fun formatTime(timeMs: Long): String { + val totalSeconds = timeMs / 1000 + val seconds = totalSeconds % 60 + val minutes = totalSeconds / 60 % 60 + val hours = totalSeconds / 3600 + val mFormatBuilder = StringBuilder() + val mFormatter = Formatter(mFormatBuilder, Locale.getDefault()) + return if (hours > 0) { + mFormatter.format("%d:%02d:%02d", hours, minutes, seconds).toString() + } else { + mFormatter.format("%02d:%02d", minutes, seconds).toString() + } + } + + @Suppress("MagicNumber") + private fun setProgress(): Long { + var position = 0L + if (playerControl == null || isDragging) { + position = 0 + } + + playerControl?.let { playerControl -> + position = playerControl.currentPosition + val duration = playerControl.duration + if (duration > 0) { + // use long to avoid overflow + val pos = 1000L * position / duration + binding.progressBar.progress = pos.toInt() + } + val percent = playerControl.bufferedPercentage + binding.progressBar.setSecondaryProgress(percent * 10) + val endTime = if (duration > 0) formatTime(duration) else "--:--" + binding.totalTimeText.text = endTime + binding.currentTimeText.text = formatTime(position) + } + + return position + } + + @Suppress("ReturnCount") + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val keyCode = event.keyCode + val uniqueDown = (event.repeatCount == 0 && event.action == KeyEvent.ACTION_DOWN) + + when (keyCode) { + KeyEvent.KEYCODE_HEADSETHOOK, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_SPACE -> { + if (uniqueDown) { + doPauseResume() + // show(sDefaultTimeout); + binding.playBtn.requestFocus() + } + return true + } + + KeyEvent.KEYCODE_MEDIA_PLAY -> { + if (uniqueDown && playerControl?.playWhenReady == false) { + playerControl?.play() + updatePausePlay() + } + return true + } + + KeyEvent.KEYCODE_MEDIA_STOP, + KeyEvent.KEYCODE_MEDIA_PAUSE + -> { + if (uniqueDown && playerControl?.playWhenReady == true) { + playerControl?.pause() + updatePausePlay() + } + return true + } + + else -> return super.dispatchKeyEvent(event) + } + } + + fun updatePausePlay() { + binding.playBtn.icon = ContextCompat.getDrawable( + context, + // use isPlaying instead of playWhenReady + // it represents only the play/pause state + // which is needed to show play/pause icons + if (playerControl?.isPlaying == true) { + R.drawable.ic_pause + } else { + R.drawable.ic_play + } + ) + binding.forwardBtn.visibility = if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_FORWARD) == true) { + VISIBLE + } else { + INVISIBLE + } + binding.rewindBtn.visibility = if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_BACK) == true) { + VISIBLE + } else { + INVISIBLE + } + } + + private fun doPauseResume() { + playerControl?.run { + if (playWhenReady) { + pause() + } else { + play() + } + } + updatePausePlay() + } + + override fun setEnabled(enabled: Boolean) { + binding.playBtn.setEnabled(enabled) + binding.forwardBtn.setEnabled(enabled) + binding.rewindBtn.setEnabled(enabled) + binding.progressBar.setEnabled(enabled) + + disableUnsupportedButtons() + + super.setEnabled(enabled) + } + + @Suppress("MagicNumber") + override fun onClick(v: View) { + playerControl?.let { playerControl -> + val playing = playerControl.playWhenReady + val id = v.id + + when (id) { + R.id.playBtn -> { + doPauseResume() + } + + R.id.rewindBtn -> { + playerControl.seekBack() + if (!playing) { + playerControl.pause() // necessary in some 2.3.x devices + } + setProgress() + } + + R.id.forwardBtn -> { + playerControl.seekForward() + if (!playing) { + playerControl.pause() // necessary in some 2.3.x devices + } + + setProgress() + } + + else -> { + } + } + } + } + + @Suppress("MagicNumber") + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (!fromUser) { + // We're not interested in programmatically generated changes to + // the progress bar's position. + return + } + + playerControl?.let { playerControl -> + val duration = playerControl.duration + val newPosition = duration * progress / 1000L + playerControl.seekTo(newPosition) + binding.currentTimeText.text = formatTime(newPosition) + } + } + + /** + * Called in devices with touchpad when the user starts to adjust the position of the seekbar's thumb. + * + * Will be followed by several onProgressChanged notifications. + */ + override fun onStartTrackingTouch(seekBar: SeekBar) { + isDragging = true // monitors the duration of dragging + handler.removeMessages(SHOW_PROGRESS) // grants no more updates with media player progress while dragging + } + + /** + * Called in devices with touchpad when the user finishes the adjusting of the seekbar. + */ + override fun onStopTrackingTouch(seekBar: SeekBar) { + isDragging = false + setProgress() + updatePausePlay() + handler.sendEmptyMessage(SHOW_PROGRESS) // grants future updates with media player progress + } + + override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) { + super.onInitializeAccessibilityEvent(event) + event.setClassName(MediaControlView::class.java.getName()) + } + + override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(info) + info.setClassName(MediaControlView::class.java.getName()) + } + + companion object { + private val TAG = MediaControlView::class.java.getSimpleName() + private const val SHOW_PROGRESS = 1 + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/CheckCurrentCredentialsOperation.java b/app/src/main/java/com/owncloud/android/operations/CheckCurrentCredentialsOperation.java new file mode 100644 index 000000000000..7304ed2b4d12 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/CheckCurrentCredentialsOperation.java @@ -0,0 +1,55 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2016 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import com.nextcloud.client.account.User; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; + +import java.util.ArrayList; + +/** + * Checks validity of currently stored credentials for a given OC account + */ +public class CheckCurrentCredentialsOperation extends SyncOperation { + + private final User user; + + public CheckCurrentCredentialsOperation(User user, FileDataStorageManager storageManager) { + super(storageManager); + this.user = user; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + boolean validAccount = user.nameEquals(getStorageManager().getUser().getAccountName()); + if (!validAccount) { + result = new RemoteOperationResult(new IllegalStateException( + "Account to validate is not the account connected to!") + ); + } else { + RemoteOperation check = new ExistenceCheckRemoteOperation(OCFile.ROOT_PATH, false); + result = check.execute(client); + ArrayList data = new ArrayList<>(); + data.add(user.toPlatformAccount()); + result.setData(data); + } + + return result; + } + +} diff --git a/app/src/main/java/com/owncloud/android/operations/CommentFileOperation.java b/app/src/main/java/com/owncloud/android/operations/CommentFileOperation.java new file mode 100644 index 000000000000..5984d179563c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/CommentFileOperation.java @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations; + +import com.nextcloud.common.NextcloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.comments.CommentFileRemoteOperation; + +/** + * Comment file + */ +public class CommentFileOperation extends RemoteOperation { + + private final String message; + private final long fileId; + + /** + * Constructor + * + * @param message Comment to store + */ + public CommentFileOperation(String message, long fileId) { + this.message = message; + this.fileId = fileId; + } + + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Override + public RemoteOperationResult run(NextcloudClient client) { + RemoteOperationResult result = new CommentFileRemoteOperation(message, fileId).execute(client); + + if (!result.isSuccess()) { + Log_OC.e(this, "File with Id " + fileId + " could not be commented"); + } + + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/CopyFileOperation.java b/app/src/main/java/com/owncloud/android/operations/CopyFileOperation.java new file mode 100644 index 000000000000..bd7d21a6db16 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/CopyFileOperation.java @@ -0,0 +1,90 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2012-2014 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 Jorge Antonio Diaz-Benito Soriano + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.resources.files.CopyFileRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; + +/** + * Operation copying an {@link OCFile} to a different folder. + * + * @author David A. Velasco + */ +public class CopyFileOperation extends SyncOperation { + + private final String srcPath; + private String targetParentPath; + + /** + * Constructor + * + * @param srcPath Remote path of the {@link OCFile} to move. + * @param targetParentPath Path to the folder where the file will be copied into. + */ + public CopyFileOperation(String srcPath, String targetParentPath, FileDataStorageManager storageManager) { + super(storageManager); + + this.srcPath = srcPath; + this.targetParentPath = targetParentPath; + if (!this.targetParentPath.endsWith(OCFile.PATH_SEPARATOR)) { + this.targetParentPath += OCFile.PATH_SEPARATOR; + } + } + + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + /// 1. check copy validity + if (targetParentPath.startsWith(srcPath)) { + return new RemoteOperationResult(ResultCode.INVALID_COPY_INTO_DESCENDANT); + } + OCFile file = getStorageManager().getFileByPath(srcPath); + if (file == null) { + return new RemoteOperationResult(ResultCode.FILE_NOT_FOUND); + } + + /// 2. remote copy + String targetPath = targetParentPath + file.getFileName(); + if (file.isFolder()) { + targetPath += OCFile.PATH_SEPARATOR; + } + + // auto rename, to allow copy + if (targetPath.equals(srcPath)) { + if (file.isFolder()) { + targetPath = targetParentPath + file.getFileName(); + } + targetPath = UploadFileOperation.getNewAvailableRemotePath(client, targetPath, null, false); + + if (file.isFolder()) { + targetPath += OCFile.PATH_SEPARATOR; + } + } + + RemoteOperationResult result = new CopyFileRemoteOperation(srcPath, targetPath, false).execute(client); + + /// 3. local copy + if (result.isSuccess()) { + getStorageManager().copyLocalFile(file, targetPath); + } + // TODO handle ResultCode.PARTIAL_COPY_DONE in client Activity, for the moment + + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/CreateFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/CreateFolderOperation.java new file mode 100644 index 000000000000..795331f6c1d1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/CreateFolderOperation.java @@ -0,0 +1,573 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; +import android.util.Pair; + +import com.nextcloud.client.account.User; +import com.nextcloud.utils.e2ee.E2EVersionHelper; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.Data; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation; +import com.owncloud.android.lib.resources.files.CreateFolderRemoteOperation; +import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.operations.common.SyncOperation; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.EncryptionUtilsV2; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.MimeType; + +import java.io.File; +import java.util.UUID; + +import androidx.annotation.NonNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import static com.owncloud.android.datamodel.OCFile.PATH_SEPARATOR; +import static com.owncloud.android.datamodel.OCFile.ROOT_PATH; + +/** + * Access to remote operation performing the creation of a new folder in the ownCloud server. Save the new folder in + * Database. + */ +public class CreateFolderOperation extends SyncOperation implements OnRemoteOperationListener { + + private static final String TAG = CreateFolderOperation.class.getSimpleName(); + + protected String remotePath; + private RemoteFile createdRemoteFolder; + private volatile boolean encrypt = false; + private final User user; + private final Context context; + + /** + * Constructor + */ + public CreateFolderOperation(String remotePath, User user, Context context, FileDataStorageManager storageManager) { + super(storageManager); + + this.remotePath = remotePath; + this.user = user; + this.context = context; + } + + public void setEncrypt(boolean value) { + encrypt = value; + } + + public boolean shouldEncrypt() { + return encrypt; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + String remoteParentPath = new File(getRemotePath()).getParent(); + remoteParentPath = remoteParentPath.endsWith(PATH_SEPARATOR) ? + remoteParentPath : remoteParentPath + PATH_SEPARATOR; + + OCFile parent = getStorageManager().getFileByDecryptedRemotePath(remoteParentPath); + + String tempRemoteParentPath = remoteParentPath; + while (parent == null) { + tempRemoteParentPath = new File(tempRemoteParentPath).getParent(); + + if (!tempRemoteParentPath.endsWith(PATH_SEPARATOR)) { + tempRemoteParentPath = tempRemoteParentPath + PATH_SEPARATOR; + } + + parent = getStorageManager().getFileByDecryptedRemotePath(tempRemoteParentPath); + } + + // check if any parent is encrypted + boolean encryptedAncestor = FileStorageUtils.checkEncryptionStatus(parent, getStorageManager()); + + if (encryptedAncestor) { + final var capability = getStorageManager().getCapability(user); + + if (E2EVersionHelper.INSTANCE.isV2Plus(capability)) { + return encryptedCreateV2(parent, client); + } else if (E2EVersionHelper.INSTANCE.isV1(capability)) { + return encryptedCreateV1(parent, client); + } + + return new RemoteOperationResult<>(new IllegalStateException("E2E not supported")); + } else { + return normalCreate(client); + } + } + + @SuppressFBWarnings( + value = "EXS_EXCEPTION_SOFTENING_NO_CONSTRAINTS", + justification = "Converting checked exception to runtime is acceptable in this context" + ) + private RemoteOperationResult encryptedCreateV1(OCFile parent, OwnCloudClient client) { + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); + String privateKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PRIVATE_KEY); + String publicKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); + + String token = null; + Boolean metadataExists; + DecryptedFolderMetadataFileV1 metadata; + String encryptedRemotePath = null; + + String filename = new File(remotePath).getName(); + + try { + // lock folder + token = EncryptionUtils.lockFolder(parent, client); + + // get metadata + Pair metadataPair = EncryptionUtils.retrieveMetadataV1(parent, + client, + privateKey, + publicKey, + arbitraryDataProvider, + user + ); + + metadataExists = metadataPair.first; + metadata = metadataPair.second; + + // check if filename already exists + if (isFileExisting(metadata, filename)) { + return new RemoteOperationResult(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS); + } + + // generate new random file name, check if it exists in metadata + String encryptedFileName = createRandomFileName(metadata); + encryptedRemotePath = parent.getRemotePath() + encryptedFileName; + + RemoteOperationResult result = new CreateFolderRemoteOperation(encryptedRemotePath, + true, + token) + .execute(client); + + if (result.isSuccess()) { + // update metadata + metadata.getFiles().put(encryptedFileName, createDecryptedFile(filename)); + + EncryptedFolderMetadataFileV1 encryptedFolderMetadata = EncryptionUtils.encryptFolderMetadata(metadata, + publicKey, + parent.getLocalId(), + user, + arbitraryDataProvider + ); + String serializedFolderMetadata = EncryptionUtils.serializeJSON(encryptedFolderMetadata); + + // upload metadata + EncryptionUtils.uploadMetadata(parent, + serializedFolderMetadata, + token, + client, + metadataExists, + E2EVersionHelper.INSTANCE.latestVersion(false), + "", + arbitraryDataProvider, + user); + + // unlock folder + if (token != null) { + RemoteOperationResult unlockFolderResult = EncryptionUtils.unlockFolderV1(parent, client, token); + + if (unlockFolderResult.isSuccess()) { + token = null; + } else { + // TODO E2E: do better + throw new RuntimeException("Could not unlock folder!"); + } + } + + final var remoteFolderOperationResult = new ReadFolderRemoteOperation(encryptedRemotePath) + .execute(client); + + if (remoteFolderOperationResult.isSuccess() && remoteFolderOperationResult.getData().get(0) instanceof RemoteFile remoteFile) { + createdRemoteFolder = remoteFile; + OCFile newDir = createRemoteFolderOcFile(parent, filename, createdRemoteFolder); + getStorageManager().saveFile(newDir); + + final var encryptionOperationResult = new ToggleEncryptionRemoteOperation( + newDir.getLocalId(), + newDir.getRemotePath(), + true) + .execute(client); + + if (!encryptionOperationResult.isSuccess()) { + throw new RuntimeException("Error creating encrypted subfolder!"); + } + } else { + throw new RuntimeException("Error creating encrypted subfolder!"); + } + } else { + // revert to sane state in case of any error + Log_OC.e(TAG, remotePath + " hasn't been created"); + } + + return result; + } catch (Exception e) { + if (!EncryptionUtils.unlockFolderV1(parent, client, token).isSuccess()) { + throw new RuntimeException("Could not clean up after failing folder creation!", e); + } + + // remove folder + if (encryptedRemotePath != null) { + RemoteOperationResult removeResult = new RemoveRemoteEncryptedFileOperation(encryptedRemotePath, + user, + context, + filename, + parent, + true + ).execute(client); + + if (!removeResult.isSuccess()) { + throw new RuntimeException("Could not clean up after failing folder creation!"); + } + } + + // TODO E2E: do better + return new RemoteOperationResult(e); + } finally { + // unlock folder + if (token != null) { + RemoteOperationResult unlockFolderResult = EncryptionUtils.unlockFolderV1(parent, client, token); + + if (!unlockFolderResult.isSuccess()) { + // TODO E2E: do better + throw new RuntimeException("Could not unlock folder!"); + } + } + } + } + + @SuppressFBWarnings( + value = "EXS_EXCEPTION_SOFTENING_NO_CONSTRAINTS", + justification = "Converting checked exception to runtime is acceptable in this context" + ) + private RemoteOperationResult encryptedCreateV2(OCFile parent, OwnCloudClient client) { + String token = null; + Boolean metadataExists; + DecryptedFolderMetadataFile metadata; + String encryptedRemotePath = null; + + String filename = new File(remotePath).getName(); + + try { + // lock folder + token = EncryptionUtils.lockFolder(parent, client); + + // get metadata + EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); + kotlin.Pair metadataPair = encryptionUtilsV2.retrieveMetadata(parent, + client, + user, + context); + + metadataExists = metadataPair.getFirst(); + metadata = metadataPair.getSecond(); + + // check if filename already exists + if (isFileExisting(metadata, filename)) { + return new RemoteOperationResult(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS); + } + + // generate new random file name, check if it exists in metadata + String encryptedFileName = createRandomFileName(metadata); + encryptedRemotePath = parent.getRemotePath() + encryptedFileName; + + RemoteOperationResult result = new CreateFolderRemoteOperation(encryptedRemotePath, + true, + token) + .execute(client); + + String remoteId = result.getResultData(); + + if (result.isSuccess()) { + DecryptedFolderMetadataFile subFolderMetadata = encryptionUtilsV2.createDecryptedFolderMetadataFile(); + + // upload metadata + encryptionUtilsV2.serializeAndUploadMetadata(remoteId, + subFolderMetadata, + token, + client, + false, + context, + user, + parent, + getStorageManager()); + } + + if (result.isSuccess()) { + // update metadata + DecryptedFolderMetadataFile updatedMetadataFile = encryptionUtilsV2.addFolderToMetadata(encryptedFileName, + filename, + metadata, + parent, + getStorageManager()); + + // upload metadata + encryptionUtilsV2.serializeAndUploadMetadata(parent, + updatedMetadataFile, + token, + client, + metadataExists, + context, + user, + getStorageManager()); + + // unlock folder + RemoteOperationResult unlockFolderResult = EncryptionUtils.unlockFolder(parent, client, token); + + if (unlockFolderResult.isSuccess()) { + token = null; + } else { + // TODO E2E: do better + throw new RuntimeException("Could not unlock folder!"); + } + + final var remoteFolderOperationResult = new ReadFolderRemoteOperation(encryptedRemotePath) + .execute(client); + + if (remoteFolderOperationResult.isSuccess() && remoteFolderOperationResult.getData().get(0) instanceof RemoteFile remoteFile) { + createdRemoteFolder = remoteFile; + OCFile newDir = createRemoteFolderOcFile(parent, filename, createdRemoteFolder); + getStorageManager().saveFile(newDir); + + final var encryptionOperationResult = new ToggleEncryptionRemoteOperation( + newDir.getLocalId(), + newDir.getRemotePath(), + true) + .execute(client); + + if (!encryptionOperationResult.isSuccess()) { + throw new RuntimeException("Error creating encrypted subfolder!"); + } + } else { + throw new RuntimeException("Error creating encrypted subfolder!"); + } + } else { + // revert to sane state in case of any error + Log_OC.e(TAG, remotePath + " hasn't been created"); + } + + return result; + } catch (Exception e) { + // TODO remove folder + + if (!EncryptionUtils.unlockFolder(parent, client, token).isSuccess()) { + throw new RuntimeException("Could not clean up after failing folder creation!", e); + } + + // remove folder + if (encryptedRemotePath != null) { + RemoteOperationResult removeResult = new RemoveRemoteEncryptedFileOperation(encryptedRemotePath, + user, + context, + filename, + parent, + true).execute(client); + + if (!removeResult.isSuccess()) { + throw new RuntimeException("Could not clean up after failing folder creation!"); + } + } + + // TODO E2E: do better + return new RemoteOperationResult(e); + } finally { + // unlock folder + if (token != null) { + RemoteOperationResult unlockFolderResult = EncryptionUtils.unlockFolder(parent, client, token); + + if (!unlockFolderResult.isSuccess()) { + // TODO E2E: do better + throw new RuntimeException("Could not unlock folder!"); + } + } + } + } + + private boolean isFileExisting(DecryptedFolderMetadataFileV1 metadata, String filename) { + for (com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile file : metadata.getFiles().values()) { + if (filename.equalsIgnoreCase(file.getEncrypted().getFilename())) { + return true; + } + } + + return false; + } + + private boolean isFileExisting(DecryptedFolderMetadataFile metadata, String filename) { + for (DecryptedFile file : metadata.getMetadata().getFiles().values()) { + if (filename.equalsIgnoreCase(file.getFilename())) { + return true; + } + } + + return false; + } + + @NonNull + private OCFile createRemoteFolderOcFile(OCFile parent, String filename, RemoteFile remoteFolder) { + OCFile newDir = new OCFile(remoteFolder.getRemotePath()); + + newDir.setMimeType(MimeType.DIRECTORY); + newDir.setParentId(parent.getFileId()); + newDir.setRemoteId(remoteFolder.getRemoteId()); + newDir.setModificationTimestamp(System.currentTimeMillis()); + newDir.setEncrypted(true); + newDir.setPermissions(remoteFolder.getPermissions()); + newDir.setDecryptedRemotePath(parent.getDecryptedRemotePath() + filename + "/"); + + return newDir; + } + + @NonNull + private com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile createDecryptedFile(String filename) { + // Key, always generate new one + byte[] key = EncryptionUtils.generateKey(); + + // IV, always generate new one + byte[] iv = EncryptionUtils.randomBytes(EncryptionUtils.ivLength); + + com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile decryptedFile = + new com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile(); + Data data = new Data(); + data.setFilename(filename); + data.setMimetype(MimeType.WEBDAV_FOLDER); + data.setKey(EncryptionUtils.encodeBytesToBase64String(key)); + + decryptedFile.setEncrypted(data); + decryptedFile.setInitializationVector(EncryptionUtils.encodeBytesToBase64String(iv)); + + return decryptedFile; + } + + @NonNull + private DecryptedFile createDecryptedFolder(String filename) { + // Key, always generate new one + byte[] key = EncryptionUtils.generateKey(); + + // IV, always generate new one + byte[] iv = EncryptionUtils.randomBytes(EncryptionUtils.ivLength); + + return new DecryptedFile(filename, + MimeType.WEBDAV_FOLDER, + EncryptionUtils.encodeBytesToBase64String(iv), + "", + EncryptionUtils.encodeBytesToBase64String(key)); + } + + @NonNull + private String createRandomFileName(DecryptedFolderMetadataFile metadata) { + String encryptedFileName = UUID.randomUUID().toString().replaceAll("-", ""); + + while (metadata.getMetadata().getFiles().get(encryptedFileName) != null) { + encryptedFileName = UUID.randomUUID().toString().replaceAll("-", ""); + } + return encryptedFileName; + } + + @NonNull + private String createRandomFileName(DecryptedFolderMetadataFileV1 metadata) { + String encryptedFileName = UUID.randomUUID().toString().replaceAll("-", ""); + + while (metadata.getFiles().get(encryptedFileName) != null) { + encryptedFileName = UUID.randomUUID().toString().replaceAll("-", ""); + } + return encryptedFileName; + } + + private RemoteOperationResult normalCreate(OwnCloudClient client) { + final var result = new CreateFolderRemoteOperation(remotePath, true).execute(client); + + if (result.isSuccess()) { + final var remoteFolderOperationResult = new ReadFolderRemoteOperation(remotePath) + .execute(client); + + if (remoteFolderOperationResult.isSuccess() && + remoteFolderOperationResult.getData().get(0) instanceof RemoteFile remoteFile) { + createdRemoteFolder = remoteFile; + } + + saveFolderInDB(); + } else { + Log_OC.e(TAG, remotePath + " hasn't been created"); + } + + return result; + } + + @Override + public void onRemoteOperationFinish(RemoteOperation operation, RemoteOperationResult result) { + if (operation instanceof CreateFolderRemoteOperation) { + onCreateRemoteFolderOperationFinish(result); + } + } + + private void onCreateRemoteFolderOperationFinish(RemoteOperationResult result) { + if (result.isSuccess()) { + saveFolderInDB(); + } else { + Log_OC.e(TAG, remotePath + " hasn't been created"); + } + } + + /** + * Save new directory in local database. + */ + private void saveFolderInDB() { + if (getStorageManager().getFileByPath(FileStorageUtils.getParentPath(remotePath)) == null) { + // When parent of remote path is not created + String[] subFolders = remotePath.split(PATH_SEPARATOR); + String composedRemotePath = ROOT_PATH; + + // For each ancestor folders create them recursively + for (String subFolder : subFolders) { + if (!subFolder.isEmpty()) { + composedRemotePath = composedRemotePath + subFolder + PATH_SEPARATOR; + remotePath = composedRemotePath; + saveFolderInDB(); + } + } + } else { + // Create directory on DB + OCFile newDir = new OCFile(remotePath); + newDir.setMimeType(MimeType.DIRECTORY); + long parentId = getStorageManager().getFileByPath(FileStorageUtils.getParentPath(remotePath)).getFileId(); + newDir.setParentId(parentId); + newDir.setRemoteId(createdRemoteFolder.getRemoteId()); + newDir.setModificationTimestamp(System.currentTimeMillis()); + newDir.setEncrypted(FileStorageUtils.checkEncryptionStatus(newDir, getStorageManager())); + newDir.setPermissions(createdRemoteFolder.getPermissions()); + getStorageManager().saveFile(newDir); + + Log_OC.d(TAG, "Create directory " + remotePath + " in Database"); + } + } + + public String getRemotePath() { + return remotePath; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/CreateShareViaLinkOperation.java b/app/src/main/java/com/owncloud/android/operations/CreateShareViaLinkOperation.java new file mode 100644 index 000000000000..9dcbaa1813dc --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/CreateShareViaLinkOperation.java @@ -0,0 +1,97 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2016 David A. Velasco + * SPDX-FileCopyrightText: 2014 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.FileUtils; +import com.owncloud.android.lib.resources.shares.CreateShareRemoteOperation; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.operations.common.SyncOperation; + +import java.util.ArrayList; + +/** + * Creates a new public share for a given file + */ +public class CreateShareViaLinkOperation extends SyncOperation { + + private String path; + private String password; + private int permissions = OCShare.NO_PERMISSION; + + public CreateShareViaLinkOperation(String path, String password, FileDataStorageManager storageManager) { + super(storageManager); + + this.path = path; + this.password = password; + } + + public CreateShareViaLinkOperation(String path, FileDataStorageManager storageManager, int permissions) { + this(path, null, storageManager); + this.permissions = permissions; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + CreateShareRemoteOperation createOp = new CreateShareRemoteOperation(path, + ShareType.PUBLIC_LINK, + "", + false, + password, + permissions); + createOp.setGetShareDetails(true); + RemoteOperationResult result = createOp.execute(client); + + if (result.isSuccess()) { + if (result.getData().size() > 0) { + Object item = result.getData().get(0); + if (item instanceof OCShare) { + updateData((OCShare) item); + } else { + ArrayList data = result.getData(); + result = new RemoteOperationResult(RemoteOperationResult.ResultCode.SHARE_NOT_FOUND); + result.setData(data); + } + } else { + result = new RemoteOperationResult(RemoteOperationResult.ResultCode.SHARE_NOT_FOUND); + } + } + + return result; + } + + private void updateData(OCShare share) { + // Update DB with the response + share.setPath(path); + share.setFolder(path.endsWith(FileUtils.PATH_SEPARATOR)); + + getStorageManager().saveShare(share); + + // Update OCFile with data from share: ShareByLink and publicLink + OCFile file = getStorageManager().getFileByEncryptedRemotePath(path); + if (file != null) { + file.setSharedViaLink(true); + getStorageManager().saveFile(file); + } + } + + public String getPath() { + return this.path; + } + + public String getPassword() { + return this.password; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/CreateShareWithShareeOperation.java b/app/src/main/java/com/owncloud/android/operations/CreateShareWithShareeOperation.java new file mode 100644 index 000000000000..28e732052c72 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/CreateShareWithShareeOperation.java @@ -0,0 +1,276 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 TSI-mc + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; +import android.text.TextUtils; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.network.ClientFactory; +import com.nextcloud.client.network.ClientFactoryImpl; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.utils.extensions.DecryptedUserExtensionsKt; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedUser; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.FileUtils; +import com.owncloud.android.lib.resources.shares.CreateShareRemoteOperation; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.lib.resources.users.GetPublicKeyRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.EncryptionUtilsV2; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Creates a new private share for a given file. + */ +public class CreateShareWithShareeOperation extends SyncOperation { + + private final String path; + private final String shareeName; + private final ShareType shareType; + private final int permissions; + private final String noteMessage; + private final String sharePassword; + private final boolean hideFileDownload; + private final long expirationDateInMillis; + private String label; + private final Context context; + private final User user; + private String attributes; + + private ArbitraryDataProvider arbitraryDataProvider; + + private static final Set supportedShareTypes = new HashSet<>(Arrays.asList(ShareType.USER, + ShareType.GROUP, + ShareType.FEDERATED, + ShareType.FEDERATED_GROUP, + ShareType.EMAIL, + ShareType.ROOM, + ShareType.CIRCLE)); + + /** + * Constructor. + * + * @param path Full path of the file/folder being shared. + * @param shareeName User or group name of the target sharee. + * @param shareType Type of share determines type of sharee; {@link ShareType#USER} and {@link ShareType#GROUP} + * are the only valid values for the moment. + * @param permissions Share permissions key as detailed in OCS + * Share API. + */ + public CreateShareWithShareeOperation(String path, + String shareeName, + ShareType shareType, + int permissions, + String noteMessage, + String sharePassword, + long expirationDateInMillis, + boolean hideFileDownload, + String attributes, + FileDataStorageManager storageManager, + Context context, + User user, + ArbitraryDataProvider arbitraryDataProvider) { + super(storageManager); + + if (!supportedShareTypes.contains(shareType)) { + throw new IllegalArgumentException("Illegal share type " + shareType); + } + this.path = path; + this.shareeName = shareeName; + this.shareType = shareType; + this.permissions = permissions; + this.expirationDateInMillis = expirationDateInMillis; + this.hideFileDownload = hideFileDownload; + this.noteMessage = noteMessage; + this.sharePassword = sharePassword; + this.context = context; + this.user = user; + this.arbitraryDataProvider = arbitraryDataProvider; + this.attributes = attributes; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + OCFile folder = getStorageManager().getFileByDecryptedRemotePath(path); + + if (folder == null) { + throw new IllegalArgumentException("Trying to share on a null folder: " + path); + } + + boolean isEncrypted = folder.isEncrypted(); + String token = null; + long newCounter = folder.getE2eCounter() + 1; + + // E2E: lock folder + if (isEncrypted) { + try { + String publicKey = EncryptionUtils.getPublicKey(user, shareeName, arbitraryDataProvider); + + if (publicKey.isEmpty()) { + NextcloudClient nextcloudClient = new ClientFactoryImpl(context).createNextcloudClient(user); + RemoteOperationResult result = new GetPublicKeyRemoteOperation(shareeName).execute(nextcloudClient); + if (result.isSuccess()) { + // store it + EncryptionUtils.savePublicKey( + user, + result.getResultData(), + shareeName, + arbitraryDataProvider + ); + } else { + RemoteOperationResult e = new RemoteOperationResult(new IllegalStateException()); + e.setMessage(context.getString(R.string.secure_share_not_set_up)); + + return e; + } + } + + token = EncryptionUtils.lockFolder(folder, client, newCounter); + } catch (UploadException | ClientFactory.CreationException e) { + return new RemoteOperationResult(e); + } + } + + CreateShareRemoteOperation operation = new CreateShareRemoteOperation( + path, + shareType, + shareeName, + false, + sharePassword, + permissions, + noteMessage, + attributes + ); + operation.setGetShareDetails(true); + RemoteOperationResult shareResult = operation.execute(client); + + if (!shareResult.isSuccess() || shareResult.getData().size() == 0) { + // something went wrong + return shareResult; + } + + // E2E: update metadata + if (isEncrypted) { + Object object = EncryptionUtils.downloadFolderMetadata(folder, + client, + context, + user + ); + + if (object instanceof DecryptedFolderMetadataFileV1) { + throw new RuntimeException("Trying to share on e2e v1!"); + } + + DecryptedFolderMetadataFile metadata = (DecryptedFolderMetadataFile) object; + + boolean metadataExists; + if (metadata == null) { + String cert = EncryptionUtils.retrievePublicKeyForUser(user, context); + metadata = new EncryptionUtilsV2().createDecryptedFolderMetadataFile(); + metadata.getUsers().add(new DecryptedUser(client.getUserId(), cert, null)); + + metadataExists = false; + } else { + metadataExists = true; + } + + EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); + + // add sharee to metadata + String publicKey = EncryptionUtils.getPublicKey(user, shareeName, arbitraryDataProvider); + + String decryptedMetadataKey = DecryptedUserExtensionsKt.findMetadataKeyByUserId(metadata.getUsers(), shareeName); + DecryptedFolderMetadataFile newMetadata = encryptionUtilsV2.addShareeToMetadata(metadata, + shareeName, + publicKey, + decryptedMetadataKey); + + // upload metadata + metadata.getMetadata().setCounter(newCounter); + try { + encryptionUtilsV2.serializeAndUploadMetadata(folder, + newMetadata, + token, + client, + metadataExists, + context, + user, + getStorageManager()); + } catch (UploadException e) { + return new RemoteOperationResult<>(new RuntimeException("Uploading metadata failed")); + } + + // E2E: unlock folder + RemoteOperationResult unlockResult = EncryptionUtils.unlockFolder(folder, client, token); + if (!unlockResult.isSuccess()) { + return new RemoteOperationResult<>(new RuntimeException("Unlock failed")); + } + } + + OCShare share = (OCShare) shareResult.getData().get(0); + + // once creating share link update other information + UpdateShareInfoOperation updateShareInfoOperation = new UpdateShareInfoOperation(share, getStorageManager()); + updateShareInfoOperation.setExpirationDateInMillis(expirationDateInMillis); + updateShareInfoOperation.setHideFileDownload(hideFileDownload); + updateShareInfoOperation.setNote(noteMessage); + updateShareInfoOperation.setLabel(label); + + //update permissions for external share (will otherwise default to read-only) + updateShareInfoOperation.setPermissions(permissions); + + // execute and save the result in database + RemoteOperationResult updateShareInfoResult = updateShareInfoOperation.execute(client); + if (updateShareInfoResult.isSuccess() && updateShareInfoResult.getData().size() > 0) { + OCShare shareUpdated = (OCShare) updateShareInfoResult.getData().get(0); + updateData(shareUpdated); + } + + return shareResult; + } + + private void updateData(OCShare share) { + // Update DB with the response + share.setPath(path); + share.setFolder(path.endsWith(FileUtils.PATH_SEPARATOR)); + share.setPasswordProtected(!TextUtils.isEmpty(sharePassword)); + getStorageManager().saveShare(share); + + // Update OCFile with data from share: ShareByLink and publicLink + OCFile file = getStorageManager().getFileByPath(path); + if (file != null) { + file.setSharedWithSharee(true); // TODO - this should be done by the FileContentProvider, as part of getStorageManager().saveShare(share) + getStorageManager().saveFile(file); + } + } + + public String getPath() { + return this.path; + } + + public void setLabel(String label) { + this.label = label; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/DetectAuthenticationMethodOperation.java b/app/src/main/java/com/owncloud/android/operations/DetectAuthenticationMethodOperation.java new file mode 100644 index 000000000000..c1a5f7ea5e35 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/DetectAuthenticationMethodOperation.java @@ -0,0 +1,130 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017-2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016-2017 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; + +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation; + +import org.apache.commons.httpclient.Header; +import org.apache.commons.httpclient.HttpStatus; + +import java.util.ArrayList; +import java.util.Locale; + +/** + * Operation to find out what authentication method requires the server to access files. + * + * Basically, tries to access to the root folder without authorization and analyzes the response. + * + * When successful, the instance of {@link RemoteOperationResult} passed through + * {@link com.owncloud.android.lib.common.operations.OnRemoteOperationListener + * #onRemoteOperationFinish(RemoteOperation, RemoteOperationResult)} returns in + * {@link RemoteOperationResult#getData()} a value of {@link AuthenticationMethod}. + */ +public class DetectAuthenticationMethodOperation extends RemoteOperation { + + private static final String TAG = DetectAuthenticationMethodOperation.class.getSimpleName(); + + public enum AuthenticationMethod { + UNKNOWN, + NONE, + BASIC_HTTP_AUTH, + SAML_WEB_SSO, + BEARER_TOKEN + } + + private Context mContext; + + /** + * Constructor + * + * @param context Android context of the caller. + */ + public DetectAuthenticationMethodOperation(Context context) { + mContext = context; + } + + + /** + * Performs the operation. + * + * Triggers a check of existence on the root folder of the server, granting + * that the request is not authenticated. + * + * Analyzes the result of check to find out what authentication method, if + * any, is requested by the server. + */ + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + AuthenticationMethod authMethod = AuthenticationMethod.UNKNOWN; + + RemoteOperation operation = new ExistenceCheckRemoteOperation("", mContext, false); + client.clearCredentials(); + client.setFollowRedirects(false); + + // try to access the root folder, following redirections but not SAML SSO redirections + RemoteOperationResult result = operation.execute(client); + String redirectedLocation = result.getRedirectedLocation(); + while (!TextUtils.isEmpty(redirectedLocation) && !result.isIdPRedirection()) { + client.setBaseUri(Uri.parse(result.getRedirectedLocation())); + result = operation.execute(client); + redirectedLocation = result.getRedirectedLocation(); + } + + // analyze response + if (result.getHttpCode() == HttpStatus.SC_UNAUTHORIZED || result.getHttpCode() == HttpStatus.SC_FORBIDDEN) { + ArrayList authHeaders = result.getAuthenticateHeaders(); + + for (String header : authHeaders) { + // currently we only support basic auth + if (header.toLowerCase(Locale.ROOT).contains("basic")) { + authMethod = AuthenticationMethod.BASIC_HTTP_AUTH; + break; + } + } + // else - fall back to UNKNOWN + + } else if (result.isSuccess()) { + authMethod = AuthenticationMethod.NONE; + + } else if (result.isIdPRedirection()) { + authMethod = AuthenticationMethod.SAML_WEB_SSO; + } + // else - fall back to UNKNOWN + Log_OC.d(TAG, "Authentication method found: " + authenticationMethodToString(authMethod)); + + if (authMethod != AuthenticationMethod.UNKNOWN) { + result = new RemoteOperationResult(true, result.getHttpCode(), result.getHttpPhrase(), new Header[0]); + } + ArrayList data = new ArrayList<>(); + data.add(authMethod); + result.setData(data); + return result; // same result instance, so that other errors + // can be handled by the caller transparently + } + + private String authenticationMethodToString(AuthenticationMethod value) { + return switch (value) { + case NONE -> "NONE"; + case BASIC_HTTP_AUTH -> "BASIC_HTTP_AUTH"; + case BEARER_TOKEN -> "BEARER_TOKEN"; + case SAML_WEB_SSO -> "SAML_WEB_SSO"; + default -> "UNKNOWN"; + }; + } + +} diff --git a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java new file mode 100644 index 000000000000..84ed094efda2 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java @@ -0,0 +1,359 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2020-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; + +import com.nextcloud.client.account.User; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.utils.extensions.ContextExtensionsKt; +import com.nextcloud.utils.extensions.OwnCloudClientExtensionsKt; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener; +import com.owncloud.android.lib.common.operations.OperationCancelledException; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.DownloadFileRemoteOperation; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.FileExportUtils; +import com.owncloud.android.utils.FileStorageUtils; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.crypto.Cipher; + +import static com.owncloud.android.utils.EncryptionUtils.decodeStringToBase64Bytes; + +/** + * Remote DownloadOperation performing the download of a file to an ownCloud server + */ +public class DownloadFileOperation extends RemoteOperation { + private static final String TAG = DownloadFileOperation.class.getSimpleName(); + + private User user; + private OCFile file; + private String behaviour; + private String etag = ""; + private String activityName; + private String packageName; + private DownloadType downloadType; + + private final WeakReference context; + + // CHECK: Is this still needed after conversion from Foreground Services to Worker? + private Set dataTransferListeners = new HashSet<>(); + + private long modificationTimestamp; + private DownloadFileRemoteOperation downloadOperation; + private final AtomicBoolean cancellationRequested = new AtomicBoolean(false); + private final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); + + public DownloadFileOperation(User user, + OCFile file, + String behaviour, + String activityName, + String packageName, + Context context, + DownloadType downloadType) { + if (user == null) { + throw new IllegalArgumentException("Illegal null user in DownloadFileOperation " + + "creation"); + } + if (file == null) { + throw new IllegalArgumentException("Illegal null file in DownloadFileOperation " + + "creation"); + } + + this.user = user; + this.file = file; + this.behaviour = behaviour; + this.activityName = activityName; + this.packageName = packageName; + this.context = new WeakReference<>(context); + this.downloadType = downloadType; + } + + public DownloadFileOperation(User user, OCFile file, Context context) { + this(user, file, null, null, null, context, DownloadType.DOWNLOAD); + } + + public boolean isMatching(String accountName, long fileId) { + return getFile().getFileId() == fileId && getUser().getAccountName().equals(accountName); + } + + public void cancelMatchingOperation(String accountName, long fileId) { + if (isMatching(accountName, fileId)) { + cancel(); + } + } + + public String getSavePath() { + if (file.getStoragePath() != null) { + File parentFile = new File(file.getStoragePath()).getParentFile(); + if (parentFile != null && !parentFile.exists()) { + try { + Files.createDirectories(parentFile.toPath()); + } catch (IOException e) { + return FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), file); + } + } + File path = new File(file.getStoragePath()); // re-downloads should be done over the original file + if (path.canWrite() || parentFile != null && parentFile.canWrite()) { + return path.getAbsolutePath(); + } + } + return FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), file); + } + + public String getTmpPath() { + return FileStorageUtils.getTemporalPath(user.getAccountName()) + file.getRemotePath(); + } + + public String getTmpFolder() { + return FileStorageUtils.getTemporalPath(user.getAccountName()); + } + + public String getRemotePath() { + return file.getRemotePath(); + } + + public String getMimeType() { + String mimeType = file.getMimeType(); + if (TextUtils.isEmpty(mimeType)) { + try { + mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension( + file.getRemotePath().substring( + file.getRemotePath().lastIndexOf('.') + 1)); + } catch (IndexOutOfBoundsException e) { + Log_OC.e(TAG, "Trying to find out MIME type of a file without extension: " + + file.getRemotePath()); + } + } + if (mimeType == null) { + mimeType = "application/octet-stream"; + } + return mimeType; + } + + public long getSize() { + return file.getFileLength(); + } + + public long getModificationTimestamp() { + return modificationTimestamp > 0 ? modificationTimestamp : file.getModificationTimestamp(); + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + /// perform the download + synchronized(cancellationRequested) { + if (cancellationRequested.get()) { + return new RemoteOperationResult<>(new OperationCancelledException()); + } + } + + final var isValidExtFilename = FileStorageUtils.isValidExtFilename(file.getFileName()); + if (!isValidExtFilename) { + mainThreadHandler.post(() -> ContextExtensionsKt.showToast(context.get(), R.string.download_download_invalid_local_file_name)); + return new RemoteOperationResult<>(RemoteOperationResult.ResultCode.INVALID_CHARACTER_IN_NAME); + } + + Context operationContext = context.get(); + if (operationContext == null) { + return new RemoteOperationResult<>(RemoteOperationResult.ResultCode.UNKNOWN_ERROR); + } + + RemoteOperationResult result; + File newFile = null; + boolean moved; + + /// download will be performed to a temporal file, then moved to the final location + File tmpFile = new File(getTmpPath()); + + String tmpFolder = getTmpFolder(); + + downloadOperation = new DownloadFileRemoteOperation(file.getRemotePath(), tmpFolder); + + if (downloadType == DownloadType.DOWNLOAD) { + dataTransferListeners.forEach(downloadOperation::addDatatransferProgressListener); + } + + NextcloudClient nextcloudClient = OwnCloudClientExtensionsKt.toNextcloudClient(client, operationContext); + result = downloadOperation.execute(nextcloudClient); + + + + if (result.isSuccess()) { + modificationTimestamp = downloadOperation.getModificationTimestamp(); + etag = downloadOperation.getEtag(); + + if (downloadType == DownloadType.DOWNLOAD) { + newFile = new File(getSavePath()); + + if (!newFile.getParentFile().exists() && !newFile.getParentFile().mkdirs()) { + Log_OC.e(TAG, "Unable to create parent folder " + newFile.getParentFile().getAbsolutePath()); + } + } + + // decrypt file + if (file.isEncrypted()) { + FileDataStorageManager fileDataStorageManager = new FileDataStorageManager(user, operationContext.getContentResolver()); + + OCFile parent = fileDataStorageManager.getFileByEncryptedRemotePath(file.getParentRemotePath()); + + Object object = EncryptionUtils.downloadFolderMetadata(parent, + client, + operationContext, + user); + + if (object == null) { + return new RemoteOperationResult(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND); + } + + String keyString; + String nonceString; + String authenticationTagString; + if (object instanceof DecryptedFolderMetadataFile) { + DecryptedFile decryptedFile = ((DecryptedFolderMetadataFile) object) + .getMetadata() + .getFiles() + .get(file.getEncryptedFileName()); + + if (decryptedFile == null) { + return new RemoteOperationResult(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND); + } + + keyString = decryptedFile.getKey(); + nonceString = decryptedFile.getNonce(); + authenticationTagString = decryptedFile.getAuthenticationTag(); + } else { + com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile decryptedFile = + ((DecryptedFolderMetadataFileV1) object) + .getFiles() + .get(file.getEncryptedFileName()); + + if (decryptedFile == null) { + return new RemoteOperationResult(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND); + } + + keyString = decryptedFile.getEncrypted().getKey(); + nonceString = decryptedFile.getInitializationVector(); + authenticationTagString = decryptedFile.getAuthenticationTag(); + } + + byte[] key = decodeStringToBase64Bytes(keyString); + byte[] iv = decodeStringToBase64Bytes(nonceString); + + try { + Cipher cipher = EncryptionUtils.getCipher(Cipher.DECRYPT_MODE, key, iv); + EncryptionUtils.decryptFile(cipher, tmpFile, newFile, authenticationTagString, new ArbitraryDataProviderImpl(operationContext), user); + } catch (Exception e) { + return new RemoteOperationResult(e); + } + } + + if (downloadType == DownloadType.DOWNLOAD && !file.isEncrypted()) { + moved = tmpFile.renameTo(newFile); + boolean isLastModifiedSet = newFile.setLastModified(file.getModificationTimestamp()); + Log_OC.d(TAG, "Last modified set: " + isLastModifiedSet); + if (!moved) { + result = new RemoteOperationResult(RemoteOperationResult.ResultCode.LOCAL_STORAGE_NOT_MOVED); + } + } else if (downloadType == DownloadType.EXPORT) { + new FileExportUtils().exportFile(file.getFileName(), + file.getMimeType(), + operationContext.getContentResolver(), + null, + tmpFile); + if (!tmpFile.delete()) { + Log_OC.e(TAG, "Deletion of " + tmpFile.getAbsolutePath() + " failed!"); + } + } + } + + Log_OC.i(TAG, "Download of " + file.getRemotePath() + " to " + getSavePath() + ": " + + result.getLogMessage()); + + return result; + } + + public void cancel() { + cancellationRequested.set(true); // atomic set; there is no need of synchronizing it + if (downloadOperation != null) { + downloadOperation.cancel(); + } + } + + + public void addDownloadDataTransferProgressListener(OnDatatransferProgressListener listener) { + synchronized (dataTransferListeners) { + dataTransferListeners.add(listener); + } + } + + public void removeDatatransferProgressListener(OnDatatransferProgressListener listener) { + synchronized (dataTransferListeners) { + dataTransferListeners.remove(listener); + } + } + + public User getUser() { + return this.user; + } + + public OCFile getFile() { + return this.file; + } + + public String getBehaviour() { + return this.behaviour; + } + + public String getEtag() { + return this.etag; + } + + public String getActivityName() { + return this.activityName; + } + + public String getPackageName() { + return this.packageName; + } + + public DownloadType getDownloadType() { + return downloadType; + } + + public void setDownloadType(DownloadType type) { + downloadType = type; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/DownloadType.kt b/app/src/main/java/com/owncloud/android/operations/DownloadType.kt new file mode 100644 index 000000000000..fafa11055e17 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/DownloadType.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations + +enum class DownloadType(var type: String) { + DOWNLOAD("DOWNLOAD"), + EXPORT("EXPORT"); + + override fun toString(): String = type +} diff --git a/app/src/main/java/com/owncloud/android/operations/FolderRefreshScheduler.kt b/app/src/main/java/com/owncloud/android/operations/FolderRefreshScheduler.kt new file mode 100644 index 000000000000..958db34e5a02 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/FolderRefreshScheduler.kt @@ -0,0 +1,101 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations + +import androidx.lifecycle.lifecycleScope +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.CheckEtagRemoteOperation +import com.owncloud.android.ui.activity.FileDisplayActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class FolderRefreshScheduler(private val activity: FileDisplayActivity) { + companion object { + private const val ETAG_POLL_INTERVAL_MS = 30_000L + private const val TAG = "FolderRefreshScheduler" + } + + private var job: Job? = null + + fun start() { + stop() + + job = activity.lifecycleScope.launch { + while (isActive) { + delay(ETAG_POLL_INTERVAL_MS) + checkAndRefreshIfETagChanged() + } + } + + Log_OC.d(TAG, "eTag polling started interval 30 seconds") + } + + fun stop() { + job?.cancel() + job = null + Log_OC.d(TAG, "eTag polling stopped") + } + + @Suppress("ReturnCount", "TooGenericExceptionCaught") + private suspend fun checkAndRefreshIfETagChanged() { + if (activity.isFinishing || activity.isSearchOpen()) { + Log_OC.w(TAG, "activity is finished or search is opened") + return + } + + val currentDir = activity.getCurrentDir() + if (currentDir == null) { + Log_OC.w(TAG, "current directory is null") + return + } + + val currentUser = activity.user.orElse(null) + if (currentUser == null) { + Log_OC.w(TAG, "current user is null") + return + } + + val localEtag = currentDir.etag ?: "" + + Log_OC.d(TAG, "eTag poll → checking '${currentDir.remotePath}' (local eTag='$localEtag')") + + val result = withContext(Dispatchers.IO) { + try { + CheckEtagRemoteOperation(currentDir.remotePath, localEtag).execute(currentUser, activity) + } catch (e: Exception) { + Log_OC.e(TAG, e.message) + null + } + } ?: return + + when (result.code) { + RemoteOperationResult.ResultCode.ETAG_CHANGED -> { + Log_OC.i(TAG, "eTag poll → eTag changed for '${currentDir.remotePath}', triggering sync") + activity.startSyncFolderOperation(currentDir, ignoreETag = true) + } + + RemoteOperationResult.ResultCode.ETAG_UNCHANGED -> { + Log_OC.d(TAG, "eTag poll → no change for '${currentDir.remotePath}'") + } + + RemoteOperationResult.ResultCode.FILE_NOT_FOUND -> { + Log_OC.w(TAG, "eTag poll → directory not found on server") + activity.startSyncFolderOperation(currentDir, ignoreETag = true) + } + + else -> { + Log_OC.w(TAG, "eTag poll → unexpected result code: ${result.code}") + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java b/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java new file mode 100644 index 000000000000..067727c519a8 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java @@ -0,0 +1,54 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Daniel Kesselberg + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.operations.common.SyncOperation; +import com.owncloud.android.utils.theme.CapabilityUtils; + +/** + * Get and save capabilities from the server + */ +public class GetCapabilitiesOperation extends SyncOperation { + + public GetCapabilitiesOperation(FileDataStorageManager storageManager) { + super(storageManager); + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + final FileDataStorageManager storageManager = getStorageManager(); + + OCCapability currentCapability = null; + if (!storageManager.getUser().isAnonymous()) { + currentCapability = storageManager.getCapability(storageManager.getUser().getAccountName()); + } + + RemoteOperationResult result = new GetCapabilitiesRemoteOperation(currentCapability).execute(client); + + if (result.isSuccess() && result.getResultData() != null) { + // Read data from the result + OCCapability capability = result.getResultData(); + + // Save the capabilities into database + storageManager.saveCapabilities(capability); + + // update cached entry + CapabilityUtils.updateCapability(capability); + } + + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/GetServerInfoOperation.java b/app/src/main/java/com/owncloud/android/operations/GetServerInfoOperation.java new file mode 100644 index 000000000000..5f402d079d8f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/GetServerInfoOperation.java @@ -0,0 +1,119 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017-2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; + +import com.owncloud.android.authentication.AuthenticatorUrlUtils; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.status.GetStatusRemoteOperation; +import com.owncloud.android.lib.resources.status.OwnCloudVersion; +import com.owncloud.android.operations.DetectAuthenticationMethodOperation.AuthenticationMethod; + +import java.util.ArrayList; +import java.util.Locale; + +/** + * Get basic information from an ownCloud server given its URL. + * Checks the existence of a configured ownCloud server in the URL, gets its version + * and finds out what authentication method is needed to access files in it. + */ +public class GetServerInfoOperation extends RemoteOperation { + + private static final String TAG = GetServerInfoOperation.class.getSimpleName(); + + private String mUrl; + private Context mContext; + private ServerInfo mResultData; + + /** + * Constructor. + * + * @param url URL to an ownCloud server. + * @param context Android context; needed to check network state + * TODO ugly dependency, get rid of it. + */ + public GetServerInfoOperation(String url, Context context) { + mUrl = AuthenticatorUrlUtils.INSTANCE.trimWebdavSuffix(url); + mContext = context; + mResultData = new ServerInfo(); + } + + /** + * Performs the operation + * + * @return Result of the operation. If successful, includes an instance of + * {@link ServerInfo} with the information retrieved from the server. + * Call {@link RemoteOperationResult#getData()}.get(0) to get it. + */ + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + + // first: check the status of the server (including its version) + GetStatusRemoteOperation getStatus = new GetStatusRemoteOperation(mContext); + + RemoteOperationResult result = getStatus.execute(client); + + if (result.isSuccess()) { + // second: get authentication method required by the server + mResultData.mVersion = (OwnCloudVersion) result.getData().get(0); + mResultData.hasExtendedSupport = (boolean) result.getData().get(1); + mResultData.mIsSslConn = result.getCode() == ResultCode.OK_SSL; + mResultData.mBaseUrl = normalizeProtocolPrefix(mUrl, mResultData.mIsSslConn); + RemoteOperationResult detectAuthResult = detectAuthorizationMethod(client); + + // third: merge results + if (detectAuthResult.isSuccess()) { + mResultData.mAuthMethod = (AuthenticationMethod) detectAuthResult.getData().get(0); + ArrayList data = new ArrayList<>(); + data.add(mResultData); + result.setData(data); + } else { + result = detectAuthResult; + } + } + return result; + } + + + private RemoteOperationResult detectAuthorizationMethod(OwnCloudClient client) { + Log_OC.d(TAG, "Trying empty authorization to detect authentication method"); + DetectAuthenticationMethodOperation operation = + new DetectAuthenticationMethodOperation(mContext); + return operation.execute(client); + } + + private String normalizeProtocolPrefix(String url, boolean isSslConn) { + if (!url.toLowerCase(Locale.ROOT).startsWith("http://") && + !url.toLowerCase(Locale.ROOT).startsWith("https://")) { + if (isSslConn) { + return "https://" + url; + } else { + return "http://" + url; + } + } + return url; + } + + + public static class ServerInfo { + public OwnCloudVersion mVersion; + public boolean hasExtendedSupport; + public String mBaseUrl = ""; + public AuthenticationMethod mAuthMethod = AuthenticationMethod.UNKNOWN; + public boolean mIsSslConn; + } + +} diff --git a/app/src/main/java/com/owncloud/android/operations/GetSharesForFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/GetSharesForFileOperation.kt new file mode 100644 index 000000000000..47634dd5fd3d --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/GetSharesForFileOperation.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2014-2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations + +import com.nextcloud.android.lib.resources.files.GetFilesDownloadLimitRemoteOperation +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.shares.GetSharesForFileRemoteOperation +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.operations.common.SyncOperation + +/** + * Provide a list shares for a specific file. + */ +class GetSharesForFileOperation( + private val path: String, + private val reshares: Boolean, + private val subfiles: Boolean, + storageManager: FileDataStorageManager +) : SyncOperation(storageManager) { + + @Suppress("DEPRECATION", "NestedBlockDepth") + @Deprecated("Deprecated in Java") + override fun run(client: OwnCloudClient): RemoteOperationResult> { + val result = GetSharesForFileRemoteOperation(path, reshares, subfiles).execute(client) + + if (result.isSuccess) { + // Update DB with the response + val shares = result.resultData + Log_OC.d(TAG, "File = $path Share list size ${shares.size}") + + val capability = storageManager.getCapability(storageManager.user) + if (capability.filesDownloadLimit.isTrue && shares.any { it.shareType == ShareType.PUBLIC_LINK }) { + val downloadLimitResult = GetFilesDownloadLimitRemoteOperation(path, subfiles).execute(client) + if (downloadLimitResult.isSuccess) { + val downloadLimits = downloadLimitResult.resultData + downloadLimits.forEach { downloadLimit -> + shares.find { share -> + share.token == downloadLimit.token + }?.fileDownloadLimit = downloadLimit + } + } + } + + storageManager.saveSharesDB(shares) + } else if (result.code == RemoteOperationResult.ResultCode.SHARE_NOT_FOUND) { + // no share on the file - remove local shares + storageManager.removeSharesForFile(path) + } + + return result + } + + companion object { + private val TAG: String = GetSharesForFileOperation::class.java.simpleName + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/GetUserProfileOperation.java b/app/src/main/java/com/owncloud/android/operations/GetUserProfileOperation.java new file mode 100644 index 000000000000..48de76bfac52 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/GetUserProfileOperation.java @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2016 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.accounts.Account; +import android.accounts.AccountManager; + +import com.nextcloud.common.NextcloudClient; +import com.owncloud.android.MainApp; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.UserInfo; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.users.GetUserInfoRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; + +/** + * Get and save user's profile from the server. + *

+ * Currently only retrieves the display name. + */ +public class GetUserProfileOperation extends SyncOperation { + + public GetUserProfileOperation(FileDataStorageManager storageManager) { + super(storageManager); + } + + /** + * Performs the operation. + * + * Target user account is implicit in 'client'. + * + * Stored account is implicit in {@link #getStorageManager()}. + * + * @return Result of the operation. If successful, includes an instance of + * {@link String} with the display name retrieved from the server. + * Call {@link RemoteOperationResult#getData()}.get(0) to get it. + */ + @Override + public RemoteOperationResult run(NextcloudClient client) { + + // get display name + RemoteOperationResult result = new GetUserInfoRemoteOperation().execute(client); + + if (result.isSuccess()) { + // store display name with account data + AccountManager accountManager = AccountManager.get(MainApp.getAppContext()); + UserInfo userInfo = result.getResultData(); + Account storedAccount = getStorageManager().getUser().toPlatformAccount(); + accountManager.setUserData(storedAccount, AccountUtils.Constants.KEY_DISPLAY_NAME, userInfo.getDisplayName()); + } + return result; + } + +} diff --git a/app/src/main/java/com/owncloud/android/operations/MoveFileOperation.java b/app/src/main/java/com/owncloud/android/operations/MoveFileOperation.java new file mode 100644 index 000000000000..8e2d1d192b5c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/MoveFileOperation.java @@ -0,0 +1,76 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.resources.files.MoveFileRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; + +/** + * Operation moving an {@link OCFile} to a different folder. + */ +public class MoveFileOperation extends SyncOperation { + + private final String srcPath; + private String targetParentPath; + + /** + * Constructor + * + * @param srcPath Remote path of the {@link OCFile} to move. + * @param targetParentPath Path to the folder where the file will be moved into. + */ + public MoveFileOperation(String srcPath, String targetParentPath, FileDataStorageManager storageManager) { + super(storageManager); + + this.srcPath = srcPath; + this.targetParentPath = targetParentPath; + if (!this.targetParentPath.endsWith(OCFile.PATH_SEPARATOR)) { + this.targetParentPath += OCFile.PATH_SEPARATOR; + } + } + + /** + * Performs the operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + /// 1. check move validity + if (targetParentPath.startsWith(srcPath)) { + return new RemoteOperationResult(ResultCode.INVALID_MOVE_INTO_DESCENDANT); + } + OCFile file = getStorageManager().getFileByPath(srcPath); + if (file == null) { + return new RemoteOperationResult(ResultCode.FILE_NOT_FOUND); + } + + /// 2. remote move + String targetPath = targetParentPath + file.getFileName(); + if (file.isFolder()) { + targetPath += OCFile.PATH_SEPARATOR; + } + RemoteOperationResult result = new MoveFileRemoteOperation(srcPath, targetPath, false).execute(client); + + /// 3. local move + if (result.isSuccess()) { + getStorageManager().moveLocalFile(file, targetPath, targetParentPath); + } + // TODO handle ResultCode.PARTIAL_MOVE_DONE in client Activity, for the moment + + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java new file mode 100644 index 000000000000..9dbfddc41289 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -0,0 +1,864 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2013 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; +import android.content.Intent; + +import com.google.common.collect.Maps; +import com.google.gson.Gson; +import com.nextcloud.android.lib.resources.directediting.DirectEditingObtainRemoteOperation; +import com.nextcloud.client.account.User; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.utils.e2ee.E2EVersionHelper; +import com.nextcloud.utils.extensions.StringExtensionsKt; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.lib.common.DirectEditing; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientFactory; +import com.owncloud.android.lib.common.UserInfo; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation; +import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.lib.resources.status.E2EVersion; +import com.owncloud.android.lib.resources.users.GetPredefinedStatusesRemoteOperation; +import com.owncloud.android.lib.resources.users.PredefinedStatus; +import com.owncloud.android.syncadapter.FileSyncAdapter; +import com.owncloud.android.utils.DataHolderUtil; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.MimeType; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.theme.CapabilityUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Vector; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import static com.owncloud.android.datamodel.OCFile.PATH_SEPARATOR; + +/** + * Remote operation performing the synchronization of the list of files contained in a folder identified with its remote + * path. Fetches the list and properties of the files contained in the given folder, including their properties, and + * updates the local database with them. Does NOT enter in the child folders to synchronize their contents also. + */ +@SuppressWarnings("PMD.AvoidDuplicateLiterals") +public class RefreshFolderOperation extends RemoteOperation { + + private static final String TAG = RefreshFolderOperation.class.getSimpleName(); + + public static final String EVENT_SINGLE_FOLDER_CONTENTS_SYNCED = + RefreshFolderOperation.class.getName() + ".EVENT_SINGLE_FOLDER_CONTENTS_SYNCED"; + public static final String EVENT_SINGLE_FOLDER_SHARES_SYNCED = + RefreshFolderOperation.class.getName() + ".EVENT_SINGLE_FOLDER_SHARES_SYNCED"; + + private boolean isMetadataSyncWorkerRunning = false; + + /** + * Time stamp for the synchronization process in progress + */ + private final long mCurrentSyncTime; + + /** + * Remote folder to synchronize + */ + private OCFile mLocalFolder; + + /** + * Access to the local database + */ + private final FileDataStorageManager fileDataStorageManager; + + /** + * Account where the file to synchronize belongs + */ + private final User user; + + /** + * Android context; necessary to send requests to the download service + */ + private final Context mContext; + + /** + * Files and folders contained in the synchronized folder after a successful operation + */ + private List mChildren; + + /** + * Counter of conflicts found between local and remote files + */ + private int mConflictsFound; + + /** + * Counter of failed operations in synchronization of kept-in-sync files + */ + private int mFailsInKeptInSyncFound; + + /** + * Map of remote and local paths to files that where locally stored in a location out of the ownCloud folder and + * couldn't be copied automatically into it + **/ + private final Map mForgottenLocalFiles; + + /** + * 'True' means that this operation is part of a full account synchronization + */ + private final boolean mSyncFullAccount; + + /** + * 'True' means that the remote folder changed and should be fetched + */ + private boolean mRemoteFolderChanged; + + /** + * 'True' means that Etag will be ignored + */ + private final boolean mIgnoreETag; + + /** + * 'True' means that no share and no capabilities will be updated + */ + private final boolean mOnlyFileMetadata; + + private final List mFilesToSyncContents; + // this will be used for every file when 'folder synchronization' replaces 'folder download' + + + /** + * Creates a new instance of {@link RefreshFolderOperation}. + * + * @param folder Folder to synchronize. + * @param currentSyncTime Time stamp for the synchronization process in progress. + * @param syncFullAccount 'True' means that this operation is part of a full account synchronization. + * @param ignoreETag 'True' means that the content of the remote folder should be fetched and updated even + * though the 'eTag' did not change. + * @param dataStorageManager Interface with the local database. + * @param user ownCloud account where the folder is located. + * @param context Application context. + */ + public RefreshFolderOperation(OCFile folder, + long currentSyncTime, + boolean syncFullAccount, + boolean ignoreETag, + FileDataStorageManager dataStorageManager, + User user, + Context context) { + mLocalFolder = folder; + mCurrentSyncTime = currentSyncTime; + mSyncFullAccount = syncFullAccount; + fileDataStorageManager = dataStorageManager; + this.user = user; + mContext = context; + mForgottenLocalFiles = new HashMap<>(); + mRemoteFolderChanged = false; + mIgnoreETag = ignoreETag; + mOnlyFileMetadata = false; + mFilesToSyncContents = new Vector<>(); + } + + /** + * Returns RefreshFolderOperation for metadata sync worker + */ + public RefreshFolderOperation(OCFile folder, + FileDataStorageManager dataStorageManager, + User user, + Context context) { + mLocalFolder = folder; + mCurrentSyncTime = System.currentTimeMillis(); + mSyncFullAccount = false; + fileDataStorageManager = dataStorageManager; + this.user = user; + mContext = context; + mForgottenLocalFiles = new HashMap<>(); + mRemoteFolderChanged = false; + mIgnoreETag = false; + mOnlyFileMetadata = true; + mFilesToSyncContents = new Vector<>(); + + // since metadata worker working in background for sub-folders no need send folder refresh event + isMetadataSyncWorkerRunning = true; + } + + public RefreshFolderOperation(OCFile folder, + long currentSyncTime, + boolean syncFullAccount, + boolean ignoreETag, + boolean onlyFileMetadata, + FileDataStorageManager dataStorageManager, + User user, + Context context) { + mLocalFolder = folder; + mCurrentSyncTime = currentSyncTime; + mSyncFullAccount = syncFullAccount; + fileDataStorageManager = dataStorageManager; + this.user = user; + mContext = context; + mForgottenLocalFiles = new HashMap<>(); + mRemoteFolderChanged = false; + mIgnoreETag = ignoreETag; + mOnlyFileMetadata = onlyFileMetadata; + mFilesToSyncContents = new Vector<>(); + } + + public int getConflictsFound() { + return mConflictsFound; + } + + public int getFailsInKeptInSyncFound() { + return mFailsInKeptInSyncFound; + } + + public Map getForgottenLocalFiles() { + return mForgottenLocalFiles; + } + + /** + * Returns the list of files and folders contained in the synchronized folder, if called after synchronization is + * complete. + * + * @return List of files and folders contained in the synchronized folder. + */ + public List getChildren() { + return mChildren; + } + + /** + * Performs the synchronization. + *

+ * {@inheritDoc} + */ + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + mFailsInKeptInSyncFound = 0; + mConflictsFound = 0; + mForgottenLocalFiles.clear(); + + if (mLocalFolder == null) { + Log_OC.e(TAG, "Local folder is null, cannot run refresh folder operation"); + return new RemoteOperationResult<>(ResultCode.FILE_NOT_FOUND); + } + + if (OCFile.ROOT_PATH.equals(mLocalFolder.getRemotePath()) && !mSyncFullAccount && !mOnlyFileMetadata) { + updateOCVersion(client); + updateUserProfile(); + } + + result = checkForChanges(client); + + if (result.isSuccess()) { + if (mRemoteFolderChanged) { + result = fetchAndSyncRemoteFolder(client); + } else { + Log_OC.d(TAG, "💾 Remote folder is not changed, getting folder content from database"); + mChildren = fileDataStorageManager.getFolderContent(mLocalFolder, false); + } + + if (result.isSuccess()) { + // request for the synchronization of KEPT-IN-SYNC file contents + startContentSynchronizations(mFilesToSyncContents); + } else { + mLocalFolder.setEtag(""); + } + + if (mLocalFolder != null) { + mLocalFolder.setLastSyncDateForData(System.currentTimeMillis()); + fileDataStorageManager.saveFile(mLocalFolder); + } else { + Log_OC.e(TAG, "Local folder is null, cannot set last sync date nor save file"); + result = new RemoteOperationResult<>(ResultCode.FILE_NOT_FOUND); + } + } + + if (!mSyncFullAccount && mRemoteFolderChanged && mLocalFolder != null && !isMetadataSyncWorkerRunning) { + sendLocalBroadcast(EVENT_SINGLE_FOLDER_CONTENTS_SYNCED, mLocalFolder.getRemotePath(), result); + } + + if (result.isSuccess() && result.getData() != null && !mSyncFullAccount && !mOnlyFileMetadata) { + final var remoteObject = result.getData(); + final ArrayList remoteFiles = new ArrayList<>(); + for (Object object: remoteObject) { + if (object instanceof RemoteFile remoteFile) { + remoteFiles.add(remoteFile); + } + } + + fileDataStorageManager.saveSharesFromRemoteFile(remoteFiles); + } + + if (!mSyncFullAccount && mLocalFolder != null && !isMetadataSyncWorkerRunning) { + sendLocalBroadcast(EVENT_SINGLE_FOLDER_SHARES_SYNCED, mLocalFolder.getRemotePath(), result); + } + + return result; + } + + private void updateOCVersion(OwnCloudClient client) { + UpdateOCVersionOperation update = new UpdateOCVersionOperation(user, mContext); + RemoteOperationResult result = update.execute(client); + if (result.isSuccess()) { + // Update Capabilities for this account + updateCapabilities(); + } + } + + private void updateUserProfile() { + try { + NextcloudClient nextcloudClient = OwnCloudClientFactory.createNextcloudClient(user, mContext); + + RemoteOperationResult result = new GetUserProfileOperation(fileDataStorageManager).execute(nextcloudClient); + if (!result.isSuccess()) { + Log_OC.w(TAG, "Couldn't update user profile from server"); + } else { + Log_OC.i(TAG, "Got display name: " + result.getResultData()); + } + } catch (AccountUtils.AccountNotFoundException | NullPointerException e) { + Log_OC.e(this, "Error updating profile", e); + } + } + + private void updateCapabilities() { + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(mContext); + String oldDirectEditingEtag = arbitraryDataProvider.getValue(user, + ArbitraryDataProvider.DIRECT_EDITING_ETAG); + + RemoteOperationResult result = new GetCapabilitiesOperation(fileDataStorageManager).execute(mContext); + if (result.isSuccess()) { + String newDirectEditingEtag = fileDataStorageManager.getCapability(user.getAccountName()).getDirectEditingEtag(); + + if (!oldDirectEditingEtag.equalsIgnoreCase(newDirectEditingEtag)) { + updateDirectEditing(arbitraryDataProvider, newDirectEditingEtag); + } + + updatePredefinedStatus(arbitraryDataProvider); + } else { + Log_OC.w(TAG, "Update Capabilities unsuccessfully"); + } + } + + private void updateDirectEditing(ArbitraryDataProvider arbitraryDataProvider, String newDirectEditingEtag) { + RemoteOperationResult result = + new DirectEditingObtainRemoteOperation().executeNextcloudClient(user, mContext); + + if (result.isSuccess()) { + DirectEditing directEditing = result.getResultData(); + String json = new Gson().toJson(directEditing); + arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), ArbitraryDataProvider.DIRECT_EDITING, json); + } else { + arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), ArbitraryDataProvider.DIRECT_EDITING); + } + + arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), + ArbitraryDataProvider.DIRECT_EDITING_ETAG, + newDirectEditingEtag); + } + + private void updatePredefinedStatus(ArbitraryDataProvider arbitraryDataProvider) { + NextcloudClient client; + + try { + client = OwnCloudClientFactory.createNextcloudClient(user, mContext); + } catch (AccountUtils.AccountNotFoundException | NullPointerException e) { + Log_OC.e(this, "Update of predefined status not possible!"); + return; + } + + RemoteOperationResult> result = + new GetPredefinedStatusesRemoteOperation().execute(client); + + if (result.isSuccess()) { + ArrayList predefinedStatuses = result.getResultData(); + String json = new Gson().toJson(predefinedStatuses); + arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), ArbitraryDataProvider.PREDEFINED_STATUS, json); + } else { + arbitraryDataProvider.deleteKeyForAccount(user.getAccountName(), ArbitraryDataProvider.PREDEFINED_STATUS); + } + } + + private RemoteOperationResult checkForChanges(OwnCloudClient client) { + mRemoteFolderChanged = true; + RemoteOperationResult result; + String remotePath = mLocalFolder.getRemotePath(); + + Log_OC.d(TAG, "Checking changes in " + user.getAccountName() + remotePath); + + // remote request + result = new ReadFileRemoteOperation(remotePath).execute(client); + + if (result.isSuccess()) { + OCFile remoteFolder = FileStorageUtils.fillOCFile((RemoteFile) result.getData().get(0)); + + if (!mIgnoreETag) { + // check if remote and local folder are different + String remoteFolderETag = remoteFolder.getEtag(); + if (remoteFolderETag != null) { + String localFolderEtag = mLocalFolder.getEtag(); + mRemoteFolderChanged = StringExtensionsKt.eTagChanged(remoteFolderETag, localFolderEtag); + Log_OC.d( + TAG, + "📂 eTag check\n" + + " Path: " + remoteFolder.getRemotePath() + "\n" + + " Local eTag: " + localFolderEtag + "\n" + + " Remote eTag: " + remoteFolderETag + "\n" + + " Changed: " + mRemoteFolderChanged + ); + } else { + Log_OC.e(TAG, "Checked " + user.getAccountName() + remotePath + ": No ETag received from server"); + } + } else { + Log_OC.d(TAG, "Ignoring eTag. mRemoteFolderChanged is true."); + } + + result = new RemoteOperationResult<>(ResultCode.OK); + + Log_OC.i(TAG, "Checked " + user.getAccountName() + remotePath + " : " + + (mRemoteFolderChanged ? "changed" : "not changed")); + + } else { + // check failed + if (result.getCode() == ResultCode.FILE_NOT_FOUND) { + removeLocalFolder(); + } + if (result.isException()) { + Log_OC.e(TAG, "Checked " + user.getAccountName() + remotePath + " : " + + result.getLogMessage(), result.getException()); + } else { + Log_OC.e(TAG, "Checked " + user.getAccountName() + remotePath + " : " + + result.getLogMessage()); + } + } + + return result; + } + + private RemoteOperationResult fetchAndSyncRemoteFolder(OwnCloudClient client) { + String remotePath = mLocalFolder.getRemotePath(); + RemoteOperationResult result = new ReadFolderRemoteOperation(remotePath).execute(client); + Log_OC.d(TAG, "⬇ eTag is changed or ignored, fetching folder: " + user.getAccountName() + remotePath); + + if (result.isSuccess()) { + synchronizeData(result.getData()); + if (mConflictsFound > 0 || mFailsInKeptInSyncFound > 0) { + result = new RemoteOperationResult(ResultCode.SYNC_CONFLICT); + // should be a different result code, but will do the job + } + } else { + if (result.getCode() == ResultCode.FILE_NOT_FOUND) { + removeLocalFolder(); + } + } + + return result; + } + + private void removeLocalFolder() { + if (fileDataStorageManager.fileExists(mLocalFolder.getFileId())) { + String currentSavePath = FileStorageUtils.getSavePath(user.getAccountName()); + fileDataStorageManager.removeFolder( + mLocalFolder, + true, + mLocalFolder.isDown() && mLocalFolder.getStoragePath().startsWith(currentSavePath) + ); + } + } + + + /** + * Synchronizes the data retrieved from the server about the contents of the target folder with the current data in + * the local database. + *

+ * Grants that mChildren is updated with fresh data after execution. + * + * @param folderAndFiles Remote folder and children files in Folder + */ + private void synchronizeData(List folderAndFiles) { + // get 'fresh data' from the database + mLocalFolder = fileDataStorageManager.getFileByPath(mLocalFolder.getRemotePath()); + + if (mLocalFolder == null) { + Log_OC.e(TAG,"mLocalFolder cannot be null"); + return; + } + + // parse data from remote folder + OCFile remoteFolder = FileStorageUtils.fillOCFile((RemoteFile) folderAndFiles.get(0)); + remoteFolder.setParentId(mLocalFolder.getParentId()); + remoteFolder.setFileId(mLocalFolder.getFileId()); + + Log_OC.d(TAG, "Remote folder path: " + mLocalFolder.getRemotePath() + " changed - starting update of local data "); + + List updatedFiles = new ArrayList<>(folderAndFiles.size() - 1); + mFilesToSyncContents.clear(); + + // if local folder is encrypted, download fresh metadata + boolean encryptedAncestor = FileStorageUtils.checkEncryptionStatus(mLocalFolder, fileDataStorageManager); + mLocalFolder.setEncrypted(encryptedAncestor); + + // update permission + mLocalFolder.setPermissions(remoteFolder.getPermissions()); + + // update richWorkspace + mLocalFolder.setRichWorkspace(remoteFolder.getRichWorkspace()); + + // update eTag + mLocalFolder.setEtag(remoteFolder.getEtag()); + + // update size + mLocalFolder.setFileLength(remoteFolder.getFileLength()); + + Object object = null; + if (mLocalFolder.isEncrypted()) { + object = getDecryptedFolderMetadata(encryptedAncestor, + mLocalFolder, + getClient(), + user, + mContext); + } + + final var capability = CapabilityUtils.getCapability(mContext); + + if (E2EVersionHelper.INSTANCE.isV2Plus(capability)) { + if (encryptedAncestor && object == null) { + throw new IllegalStateException("metadata is null!"); + } + } + + // get current data about local contents of the folder to synchronize + Map localFilesMap; + E2EVersion e2EVersion; + if (object instanceof DecryptedFolderMetadataFileV1 metadataFileV1) { + e2EVersion = E2EVersionHelper.INSTANCE.latestVersion(false); + localFilesMap = prefillLocalFilesMap(metadataFileV1, fileDataStorageManager.getFolderContent(mLocalFolder, false)); + } else { + e2EVersion = E2EVersionHelper.INSTANCE.latestVersion(true); + localFilesMap = prefillLocalFilesMap(object, fileDataStorageManager.getFolderContent(mLocalFolder, false)); + + // update counter + if (object != null) { + mLocalFolder.setE2eCounter(((DecryptedFolderMetadataFile) object).getMetadata().getCounter()); + } + } + + // loop to update every child + OCFile remoteFile; + OCFile localFile; + OCFile updatedFile; + RemoteFile remote; + + for (int i = 1; i < folderAndFiles.size(); i++) { + /// new OCFile instance with the data from the server + remote = (RemoteFile) folderAndFiles.get(i); + remoteFile = FileStorageUtils.fillOCFile(remote); + + // new OCFile instance to merge fresh data from server with local state + updatedFile = FileStorageUtils.fillOCFile(remote); + updatedFile.setParentId(mLocalFolder.getFileId()); + + // retrieve local data for the read file + localFile = localFilesMap.remove(remoteFile.getRemotePath()); + + // TODO better implementation is needed + if (localFile == null) { + localFile = fileDataStorageManager.getFileByPath(updatedFile.getRemotePath()); + } + + // add to updatedFile data about LOCAL STATE (not existing in server) + updatedFile.setLastSyncDateForProperties(mCurrentSyncTime); + + // keep thumbnail info + if (!updatedFile.isUpdateThumbnailNeeded() && localFile != null && localFile.getImageDimension() != null) { + updatedFile.setImageDimension(localFile.getImageDimension()); + } + + // add to updatedFile data from local and remote file + setLocalFileDataOnUpdatedFile(remoteFile, localFile, updatedFile, mRemoteFolderChanged); + + // check and fix, if needed, local storage path + FileStorageUtils.searchForLocalFileInDefaultPath(updatedFile, user.getAccountName()); + + // update file name for encrypted files + if (e2EVersion == E2EVersionHelper.INSTANCE.latestVersion(false)) { + updateFileNameForEncryptedFileV1(fileDataStorageManager, + (DecryptedFolderMetadataFileV1) object, + updatedFile); + } else if (object != null) { + updateFileNameForEncryptedFile(fileDataStorageManager, + (DecryptedFolderMetadataFile) object, + updatedFile); + if (localFile != null) { + updatedFile.setE2eCounter(localFile.getE2eCounter()); + } + } + + // we parse content, so either the folder itself or its direct parent (which we check) must be encrypted + boolean encrypted = updatedFile.isEncrypted() || mLocalFolder.isEncrypted(); + updatedFile.setEncrypted(encrypted); + + updatedFiles.add(updatedFile); + } + + + // save updated contents in local database + // update file name for encrypted files + if (e2EVersion == E2EVersionHelper.INSTANCE.latestVersion(false)) { + updateFileNameForEncryptedFileV1(fileDataStorageManager, + (DecryptedFolderMetadataFileV1) object, + mLocalFolder); + } else { + updateFileNameForEncryptedFile(fileDataStorageManager, + (DecryptedFolderMetadataFile) object, + mLocalFolder); + } + fileDataStorageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values()); + + mChildren = updatedFiles; + } + + @Nullable + public static Object getDecryptedFolderMetadata(boolean encryptedAncestor, + OCFile localFolder, + OwnCloudClient client, + User user, + Context context) { + Object metadata; + if (encryptedAncestor) { + metadata = EncryptionUtils.downloadFolderMetadata(localFolder, client, context, user); + } else { + metadata = null; + } + return metadata; + } + + @SuppressFBWarnings("CE") + private static void setMimeTypeAndDecryptedRemotePath(OCFile updatedFile, FileDataStorageManager storageManager, String decryptedFileName, String mimetype) { + OCFile parentFile = storageManager.getFileById(updatedFile.getParentId()); + + if (parentFile == null) { + throw new NullPointerException("parentFile cannot be null"); + } + + String decryptedRemotePath; + if (decryptedFileName != null) { + decryptedRemotePath = parentFile.getDecryptedRemotePath() + decryptedFileName; + } else { + decryptedRemotePath = parentFile.getRemotePath() + updatedFile.getFileName(); + } + + if (updatedFile.isFolder()) { + decryptedRemotePath += "/"; + } + updatedFile.setDecryptedRemotePath(decryptedRemotePath); + + if (mimetype == null || mimetype.isEmpty()) { + if (updatedFile.isFolder()) { + updatedFile.setMimeType(MimeType.DIRECTORY); + } else { + updatedFile.setMimeType("application/octet-stream"); + } + } else { + updatedFile.setMimeType(mimetype); + } + } + + public static void updateFileNameForEncryptedFileV1(FileDataStorageManager storageManager, + @NonNull DecryptedFolderMetadataFileV1 metadata, + OCFile updatedFile) { + try { + String decryptedFileName; + String mimetype; + + if (updatedFile.isFolder()) { + decryptedFileName = metadata.getFiles().get(updatedFile.getFileName()).getEncrypted().getFilename(); + mimetype = MimeType.DIRECTORY; + } else { + com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile decryptedFile = + metadata.getFiles().get(updatedFile.getFileName()); + + if (decryptedFile == null) { + throw new NullPointerException("decryptedFile cannot be null"); + } + + decryptedFileName = decryptedFile.getEncrypted().getFilename(); + mimetype = decryptedFile.getEncrypted().getMimetype(); + } + + setMimeTypeAndDecryptedRemotePath(updatedFile, storageManager, decryptedFileName, mimetype); + } catch (NullPointerException e) { + Log_OC.e(TAG, "DecryptedMetadata for file " + updatedFile.getFileId() + " not found!"); + } + } + + public static void updateFileNameForEncryptedFile(FileDataStorageManager storageManager, + @NonNull DecryptedFolderMetadataFile metadata, + OCFile updatedFile) { + try { + String decryptedFileName; + String mimetype; + + if (updatedFile.isFolder()) { + decryptedFileName = metadata.getMetadata().getFolders().get(updatedFile.getFileName()); + mimetype = MimeType.DIRECTORY; + } else { + DecryptedFile decryptedFile = metadata.getMetadata().getFiles().get(updatedFile.getFileName()); + + if (decryptedFile == null) { + throw new NullPointerException("decryptedFile cannot be null"); + } + + decryptedFileName = decryptedFile.getFilename(); + mimetype = decryptedFile.getMimetype(); + } + + setMimeTypeAndDecryptedRemotePath(updatedFile, storageManager, decryptedFileName, mimetype); + } catch (NullPointerException e) { + Log_OC.e(TAG, "DecryptedMetadata for file " + updatedFile.getFileId() + " not found!"); + } + } + + private void setLocalFileDataOnUpdatedFile(OCFile remoteFile, OCFile localFile, OCFile updatedFile, boolean remoteFolderChanged) { + if (localFile != null) { + updatedFile.setFileId(localFile.getFileId()); + updatedFile.setLastSyncDateForData(localFile.getLastSyncDateForData()); + updatedFile.setInternalFolderSyncTimestamp(localFile.getInternalFolderSyncTimestamp()); + updatedFile.setModificationTimestampAtLastSyncForData( + localFile.getModificationTimestampAtLastSyncForData() + ); + if (localFile.isEncrypted()) { + if (mLocalFolder.getStoragePath() == null) { + updatedFile.setStoragePath(FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mLocalFolder) + + localFile.getFileName()); + } else { + updatedFile.setStoragePath(mLocalFolder.getStoragePath() + + PATH_SEPARATOR + + localFile.getFileName()); + } + } else { + updatedFile.setStoragePath(localFile.getStoragePath()); + } + + // eTag will not be updated unless file CONTENTS are synchronized + if (!updatedFile.isFolder() && localFile.isDown() && + !updatedFile.getEtag().equals(localFile.getEtag())) { + updatedFile.setEtagInConflict(updatedFile.getEtag()); + } + + updatedFile.setEtag(localFile.getEtag()); + + if (updatedFile.isFolder()) { + updatedFile.setFileLength(remoteFile.getFileLength()); + updatedFile.setMountType(remoteFile.getMountType()); + } else if (remoteFolderChanged && MimeTypeUtil.isImage(remoteFile) && + remoteFile.getModificationTimestamp() != + localFile.getModificationTimestamp()) { + updatedFile.setUpdateThumbnailNeeded(true); + Log_OC.d(TAG, "Image " + remoteFile.getFileName() + " updated on the server"); + } + + updatedFile.setSharedViaLink(localFile.isSharedViaLink()); + updatedFile.setSharedWithSharee(localFile.isSharedWithSharee()); + } else { + // remote eTag will not be updated unless file CONTENTS are synchronized + updatedFile.setEtag(""); + } + + // eTag on Server is used for thumbnail validation + updatedFile.setEtagOnServer(remoteFile.getEtag()); + } + + @NonNull + @SuppressFBWarnings("OCP") + public static Map prefillLocalFilesMap(Object metadata, List localFiles) { + Map localFilesMap = Maps.newHashMapWithExpectedSize(localFiles.size()); + + for (OCFile file : localFiles) { + String remotePath = file.getRemotePath(); + + if (metadata != null) { + remotePath = file.getParentRemotePath() + file.getEncryptedFileName(); + if (file.isFolder() && !remotePath.endsWith(PATH_SEPARATOR)) { + remotePath = remotePath + PATH_SEPARATOR; + } + } + localFilesMap.put(remotePath, file); + } + return localFilesMap; + } + + /** + * Performs a list of synchronization operations, determining if a download or upload is needed or if exists + * conflict due to changes both in local and remote contents of the each file. + *

+ * If download or upload is needed, request the operation to the corresponding service and goes on. + * + * @param filesToSyncContents Synchronization operations to execute. + */ + private void startContentSynchronizations(List filesToSyncContents) { + RemoteOperationResult contentsResult; + for (SynchronizeFileOperation op : filesToSyncContents) { + contentsResult = op.execute(mContext); // async + if (!contentsResult.isSuccess()) { + if (contentsResult.getCode() == ResultCode.SYNC_CONFLICT) { + mConflictsFound++; + } else { + mFailsInKeptInSyncFound++; + if (contentsResult.getException() != null) { + Log_OC.e(TAG, "Error while synchronizing favourites : " + + contentsResult.getLogMessage(), contentsResult.getException()); + } else { + Log_OC.e(TAG, "Error while synchronizing favourites : " + + contentsResult.getLogMessage()); + } + } + } // won't let these fails break the synchronization process + } + } + + /** + * Sends a message to any application component interested in the progress of the synchronization. + * + * @param event broadcast event (Intent Action) + * @param dirRemotePath Remote path of a folder that was just synchronized (with or without success) + * @param result remote operation result + */ + private void sendLocalBroadcast(String event, String dirRemotePath, RemoteOperationResult result) { + Log_OC.d(TAG, "Send broadcast " + event); + Intent intent = new Intent(event); + intent.putExtra(FileSyncAdapter.EXTRA_ACCOUNT_NAME, user.getAccountName()); + + if (dirRemotePath != null) { + intent.putExtra(FileSyncAdapter.EXTRA_FOLDER_PATH, dirRemotePath); + } + + DataHolderUtil dataHolderUtil = DataHolderUtil.getInstance(); + String dataHolderItemId = dataHolderUtil.nextItemId(); + dataHolderUtil.save(dataHolderItemId, result); + intent.putExtra(FileSyncAdapter.EXTRA_RESULT, dataHolderItemId); + + intent.setPackage(mContext.getPackageName()); + LocalBroadcastManager.getInstance(mContext.getApplicationContext()).sendBroadcast(intent); + } +} diff --git a/src/main/java/com/owncloud/android/operations/RemoteOperationFailedException.java b/app/src/main/java/com/owncloud/android/operations/RemoteOperationFailedException.java similarity index 82% rename from src/main/java/com/owncloud/android/operations/RemoteOperationFailedException.java rename to app/src/main/java/com/owncloud/android/operations/RemoteOperationFailedException.java index 2549a260977a..c7971057965e 100644 --- a/src/main/java/com/owncloud/android/operations/RemoteOperationFailedException.java +++ b/app/src/main/java/com/owncloud/android/operations/RemoteOperationFailedException.java @@ -1,9 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017-2019 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ package com.owncloud.android.operations; /** * RuntimeException for throwing errors of remote operation calls. */ public class RemoteOperationFailedException extends RuntimeException { + private static final long serialVersionUID = 5429778514835938713L; + /** * Constructs a new runtime exception with the specified detail message and * cause. diff --git a/app/src/main/java/com/owncloud/android/operations/RemoveFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/RemoveFileOperation.kt new file mode 100644 index 000000000000..d1fd55dc8143 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/RemoveFileOperation.kt @@ -0,0 +1,99 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations +import android.content.Context +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation +import com.owncloud.android.operations.common.SyncOperation +import com.owncloud.android.utils.MimeTypeUtil + +/** + * Remote operation to remove a remote file or folder from an ownCloud server. + * + * @param file OCFile instance representing the remote file or folder to remove. + * @param onlyLocalCopy If true, only the local copy will be removed (if it exists). + * @param user User account associated with the operation. + * @param isInBackground Flag indicating if the operation runs in the background. + * @param context Android context. + * @param storageManager Storage manager handling local file operations. + */ +@Suppress("LongParameterList") +class RemoveFileOperation( + val file: OCFile, + private val onlyLocalCopy: Boolean, + private val user: User, + val isInBackground: Boolean, + private val context: Context, + storageManager: FileDataStorageManager +) : SyncOperation(storageManager) { + + /** + * Executes the remove operation. + * + * If the file is an image, it will also be removed from the thumbnail cache. + * Handles both encrypted and non-encrypted files. Removes the file locally if needed. + * + * @param client OwnCloudClient used to communicate with the remote server. + * @return RemoteOperationResult indicating success or failure of the operation. + */ + override fun run(client: OwnCloudClient?): RemoteOperationResult<*> { + var result: RemoteOperationResult<*>? = null + val operation: RemoteOperation<*>? + + var localRemovalFailed = false + + if (onlyLocalCopy) { + // generate resize image if image is deleted only locally, to save server request + if (MimeTypeUtil.isImage(file.mimeType)) { + ThumbnailsCacheManager.generateResizedImage(file) + } + + localRemovalFailed = !storageManager.removeFile(file, false, true) + if (!localRemovalFailed) { + result = RemoteOperationResult(RemoteOperationResult.ResultCode.OK) + } + } else { + operation = if (file.isEncrypted) { + val parent = storageManager.getFileById(file.parentId) + if (parent == null) { + return RemoteOperationResult(RemoteOperationResult.ResultCode.LOCAL_FILE_NOT_FOUND) + } + RemoveRemoteEncryptedFileOperation( + file.remotePath, + user, + context, + file.getEncryptedFileName(), + parent, + file.isFolder + ) + } else { + RemoveFileRemoteOperation(file.remotePath) + } + + result = operation.execute(client) + if (result.isSuccess || result.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND) { + localRemovalFailed = !storageManager.removeFile(file, true, true) + } + } + + if (localRemovalFailed) { + result = RemoteOperationResult(RemoteOperationResult.ResultCode.LOCAL_STORAGE_NOT_REMOVED) + } + + return result ?: RemoteOperationResult(RemoteOperationResult.ResultCode.CANCELLED) + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/RemoveRemoteEncryptedFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/RemoveRemoteEncryptedFileOperation.kt new file mode 100644 index 000000000000..e1b47c58e381 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/RemoveRemoteEncryptedFileOperation.kt @@ -0,0 +1,198 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations + +import android.content.Context +import androidx.core.util.component1 +import androidx.core.util.component2 +import com.nextcloud.client.account.User +import com.nextcloud.utils.e2ee.E2EVersionHelper +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.EncryptionUtilsV2 +import com.owncloud.android.utils.theme.CapabilityUtils +import org.apache.commons.httpclient.HttpStatus +import org.apache.commons.httpclient.NameValuePair +import org.apache.jackrabbit.webdav.client.methods.DeleteMethod + +/** + * Remote operation performing the removal of a remote encrypted file or folder + * + * Constructor + * + * @param remotePath RemotePath of the remote file or folder to remove from the server + * @param parentFolder parent folder + */ + +@Suppress("LongParameterList") +class RemoveRemoteEncryptedFileOperation internal constructor( + private val remotePath: String, + private val user: User, + private val context: Context, + private val fileName: String, + private val parentFolder: OCFile, + private val isFolder: Boolean +) : RemoteOperation() { + + /** + * Performs the remove operation. + */ + @Deprecated("Deprecated in Java") + @Suppress("TooGenericExceptionCaught") + override fun run(client: OwnCloudClient): RemoteOperationResult { + var result: RemoteOperationResult + var delete: DeleteMethod? = null + var token: String? = null + val capability = CapabilityUtils.getCapability(context) + val isE2EVersionAtLeast2 = (E2EVersionHelper.isV2Plus(capability)) + + try { + token = EncryptionUtils.lockFolder(parentFolder, client) + + return if (isE2EVersionAtLeast2) { + val deleteResult = deleteForV2(client, token) + result = deleteResult.first + delete = deleteResult.second + result + } else { + val deleteResult = deleteForV1(client, token) + result = deleteResult.first + delete = deleteResult.second + result + } + } catch (e: Exception) { + result = RemoteOperationResult(e) + Log_OC.e(TAG, "Remove " + remotePath + ": " + result.logMessage, e) + } finally { + delete?.releaseConnection() + token?.let { unlockFile(client, it, isE2EVersionAtLeast2) } + } + + return result + } + + private fun unlockFile(client: OwnCloudClient, token: String, isE2EVersionAtLeast2: Boolean) { + val unlockFileOperationResult = if (isE2EVersionAtLeast2) { + EncryptionUtils.unlockFolder(parentFolder, client, token) + } else { + EncryptionUtils.unlockFolderV1(parentFolder, client, token) + } + + if (!unlockFileOperationResult.isSuccess) { + Log_OC.e(TAG, "Failed to unlock " + parentFolder.localId) + } + } + + private fun deleteRemoteFile( + client: OwnCloudClient, + token: String? + ): Pair, DeleteMethod> { + val delete = DeleteMethod(client.getFilesDavUri(remotePath)).apply { + setQueryString(arrayOf(NameValuePair(E2E_TOKEN, token))) + } + + val status = client.executeMethod(delete, REMOVE_READ_TIMEOUT, REMOVE_CONNECTION_TIMEOUT) + delete.getResponseBodyAsString() // exhaust the response, although not interesting + + val result = RemoteOperationResult(delete.succeeded() || status == HttpStatus.SC_NOT_FOUND, delete) + Log_OC.i(TAG, "Remove " + remotePath + ": " + result.logMessage) + + return Pair(result, delete) + } + + private fun deleteForV1(client: OwnCloudClient, token: String?): Pair, DeleteMethod> { + @Suppress("DEPRECATION") + val arbitraryDataProvider: ArbitraryDataProvider = ArbitraryDataProviderImpl(context) + val privateKey = arbitraryDataProvider.getValue(user.accountName, EncryptionUtils.PRIVATE_KEY) + val publicKey = arbitraryDataProvider.getValue(user.accountName, EncryptionUtils.PUBLIC_KEY) + + val (metadataExists, metadata) = EncryptionUtils.retrieveMetadataV1( + parentFolder, + client, + privateKey, + publicKey, + arbitraryDataProvider, + user + ) + + val (result, delete) = deleteRemoteFile(client, token) + + if (!isFolder) { + EncryptionUtils.removeFileFromMetadata(fileName, metadata) + } + + val encryptedFolderMetadata = EncryptionUtils.encryptFolderMetadata( + metadata, + publicKey, + parentFolder.localId, + user, + arbitraryDataProvider + ) + + val serializedFolderMetadata = EncryptionUtils.serializeJSON(encryptedFolderMetadata) + + EncryptionUtils.uploadMetadata( + parentFolder, + serializedFolderMetadata, + token, + client, + metadataExists, + E2EVersionHelper.latestVersion(false), + "", + arbitraryDataProvider, + user + ) + + return Pair(result, delete) + } + + private fun deleteForV2(client: OwnCloudClient, token: String?): Pair, DeleteMethod> { + val encryptionUtilsV2 = EncryptionUtilsV2() + + val (metadataExists, metadata) = encryptionUtilsV2.retrieveMetadata( + parentFolder, + client, + user, + context + ) + + val (result, delete) = deleteRemoteFile(client, token) + + if (isFolder) { + encryptionUtilsV2.removeFolderFromMetadata(fileName, metadata) + } else { + encryptionUtilsV2.removeFileFromMetadata(fileName, metadata) + } + + encryptionUtilsV2.serializeAndUploadMetadata( + parentFolder, + metadata, + token!!, + client, + metadataExists, + context, + user, + FileDataStorageManager(user, context.contentResolver) + ) + + return Pair(result, delete) + } + + companion object { + private val TAG = RemoveRemoteEncryptedFileOperation::class.java.getSimpleName() + private const val REMOVE_READ_TIMEOUT = 30000 + private const val REMOVE_CONNECTION_TIMEOUT = 5000 + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/RenameFileOperation.java b/app/src/main/java/com/owncloud/android/operations/RenameFileOperation.java new file mode 100644 index 000000000000..93e60b156689 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/RenameFileOperation.java @@ -0,0 +1,200 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018-2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.text.TextUtils; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.RenameFileRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.MimeTypeUtil; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Remote operation performing the rename of a remote file (or folder?) in the ownCloud server. + */ +public class RenameFileOperation extends SyncOperation { + + private static final String TAG = RenameFileOperation.class.getSimpleName(); + + private OCFile file; + private String remotePath; + private String newName; + + /** + * Constructor + * + * @param remotePath RemotePath of the OCFile instance describing the remote file or folder to rename + * @param newName New name to set as the name of file. + */ + public RenameFileOperation(String remotePath, String newName, FileDataStorageManager storageManager) { + super(storageManager); + + this.remotePath = remotePath; + this.newName = newName; + } + + /** + * Performs the rename operation. + * + * @param client Client object to communicate with the remote ownCloud server. + */ + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result = null; + String newRemotePath = null; + + file = getStorageManager().getFileByPath(remotePath); + + // check if the new name is valid in the local file system + try { + if (!isValidNewName()) { + return new RemoteOperationResult(ResultCode.INVALID_LOCAL_FILE_NAME); + } + String parent = new File(file.getRemotePath()).getParent(); + parent = parent.endsWith(OCFile.PATH_SEPARATOR) ? parent : parent + OCFile.PATH_SEPARATOR; + newRemotePath = parent + newName; + if (file.isFolder()) { + newRemotePath += OCFile.PATH_SEPARATOR; + } + + // check local overwrite + if (getStorageManager().getFileByPath(newRemotePath) != null) { + return new RemoteOperationResult(ResultCode.INVALID_OVERWRITE); + } + + result = new RenameFileRemoteOperation(file.getFileName(), + file.getRemotePath(), + newName, + file.isFolder()) + .execute(client); + + if (result.isSuccess()) { + if (file.isFolder()) { + getStorageManager().moveLocalFile(file, newRemotePath, parent); + //saveLocalDirectory(); + + } else { + saveLocalFile(newRemotePath); + } + } + + } catch (IOException e) { + Log_OC.e(TAG, "Rename " + file.getRemotePath() + " to " + ((newRemotePath == null) ? + newName : newRemotePath) + ": " + + (result!= null ? result.getLogMessage() : ""), e); + } + + return result; + } + + private void saveLocalFile(String newRemotePath) { + file.setFileName(newName); + + if (!file.isEncrypted()) { + file.setDecryptedRemotePath(newRemotePath); + } + + // try to rename the local copy of the file + if (file.isDown()) { + String oldPath = file.getStoragePath(); + File f = new File(oldPath); + String parentStoragePath = f.getParent(); + if (!parentStoragePath.endsWith(File.separator)) { + parentStoragePath += File.separator; + } + if (f.renameTo(new File(parentStoragePath + newName))) { + String newPath = parentStoragePath + newName; + file.setStoragePath(newPath); + + // notify MediaScanner about removed file + getStorageManager().deleteFileInMediaScan(oldPath); + // notify to scan about new file, if it is a media file + if (MimeTypeUtil.isMedia(file.getMimeType())) { + FileDataStorageManager.triggerMediaScan(newPath, file); + } + } + // else - NOTHING: the link to the local file is kept although the local name + // can't be updated + // TODO - study conditions when this could be a problem + } + + getStorageManager().saveFile(file); + } + + /** + * Checks if the new name to set is valid in the file system + * + * The only way to be sure is trying to create a file with that name. It's made in the + * temporal directory for downloads, out of any account, and then removed. + * + * IMPORTANT: The test must be made in the same file system where files are download. + * The internal storage could be formatted with a different file system. + * + * TODO move this method, and maybe FileDownload.get***Path(), to a class with utilities + * specific for the interactions with the file system + * + * @return 'True' if a temporal file named with the name to set could be + * created in the file system where local files are stored. + * @throws IOException When the temporal folder can not be created. + */ + private boolean isValidNewName() throws IOException { + // check tricky names + if (TextUtils.isEmpty(newName) || newName.contains(File.separator)) { + return false; + } + // create a test file + String tmpFolderName = FileStorageUtils.getTemporalPath(""); + Path testFile = Paths.get(tmpFolderName, newName); + Path tmpFolder = testFile.getParent(); + if (tmpFolder != null && !Files.exists(tmpFolder)) { + try { + Files.createDirectories(tmpFolder); + } catch (IOException e) { + Log_OC.e(TAG, "Unable to create parent folder " + tmpFolder.toAbsolutePath()); + } + } + if (tmpFolder != null && !Files.isDirectory(tmpFolder)) { + throw new IOException("Unexpected error: temporal directory could not be created"); + } + try { + Files.createFile(testFile); + } catch (Exception e) { + Log_OC.i(TAG, "Test for validity of name " + newName + " in the file system failed"); + return false; + } + boolean result = Files.exists(testFile) && Files.isRegularFile(testFile); + + try { + Files.deleteIfExists(testFile); + } catch (Exception e) { + Log_OC.e("Error deleting file: ", e.getMessage()); + return true; + } + + return result; + } + + public OCFile getFile() { + return this.file; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/RichDocumentsCreateAssetOperation.java b/app/src/main/java/com/owncloud/android/operations/RichDocumentsCreateAssetOperation.java new file mode 100644 index 000000000000..e593a20c043f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/RichDocumentsCreateAssetOperation.java @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations; + +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.httpclient.methods.Utf8PostMethod; +import org.json.JSONObject; + +/** + * Create asset for RichDocuments app from file, which is already stored on Nextcloud server + */ + +public class RichDocumentsCreateAssetOperation extends RemoteOperation { + private static final String TAG = RichDocumentsCreateAssetOperation.class.getSimpleName(); + private static final int SYNC_READ_TIMEOUT = 40000; + private static final int SYNC_CONNECTION_TIMEOUT = 5000; + private static final String ASSET_URL = "/index.php/apps/richdocuments/assets"; + + private static final String NODE_URL = "url"; + private static final String PARAMETER_PATH = "path"; + private static final String PARAMETER_FORMAT = "format"; + private static final String PARAMETER_FORMAT_VALUE = "json"; + + private String path; + + public RichDocumentsCreateAssetOperation(String path) { + this.path = path; + } + + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + Utf8PostMethod postMethod = null; + + try { + postMethod = new Utf8PostMethod(client.getBaseUri() + ASSET_URL); + postMethod.setParameter(PARAMETER_PATH, path); + postMethod.setParameter(PARAMETER_FORMAT, PARAMETER_FORMAT_VALUE); + + // remote request + postMethod.addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE); + + int status = client.executeMethod(postMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT); + + if (status == HttpStatus.SC_OK) { + String response = postMethod.getResponseBodyAsString(); + + // Parse the response + JSONObject respJSON = new JSONObject(response); + String url = respJSON.getString(NODE_URL); + + result = new RemoteOperationResult(true, postMethod); + result.setSingleData(url); + } else { + result = new RemoteOperationResult(false, postMethod); + client.exhaustResponse(postMethod.getResponseBodyAsStream()); + } + } catch (Exception e) { + result = new RemoteOperationResult(e); + Log_OC.e(TAG, "Create asset for richdocuments with path " + path + " failed: " + result.getLogMessage(), + result.getException()); + } finally { + if (postMethod != null) { + postMethod.releaseConnection(); + } + } + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/RichDocumentsUrlOperation.java b/app/src/main/java/com/owncloud/android/operations/RichDocumentsUrlOperation.java new file mode 100644 index 000000000000..a05a8925cc74 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/RichDocumentsUrlOperation.java @@ -0,0 +1,85 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations; + +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.NextcloudServer; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.httpclient.methods.Utf8PostMethod; +import org.json.JSONObject; + +/** + * Edit a file with Richdocuments. Returns URL which can be shown in WebView. + */ +public class RichDocumentsUrlOperation extends RemoteOperation { + + /** + * TODO move to library + */ + + private static final String TAG = RichDocumentsUrlOperation.class.getSimpleName(); + private static final int SYNC_READ_TIMEOUT = 40000; + private static final int SYNC_CONNECTION_TIMEOUT = 5000; + private static final String DOCUMENT_URL = "/ocs/v2.php/apps/richdocuments/api/v1/document"; + private static final String FILE_ID = "fileId"; + + // JSON node names + private static final String NODE_OCS = "ocs"; + private static final String NODE_DATA = "data"; + private static final String NODE_URL = "url"; + private static final String JSON_FORMAT = "?format=json"; + + private final long fileId; + + public RichDocumentsUrlOperation(long fileID) { + this.fileId = fileID; + } + + @NextcloudServer(max = 18) + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + Utf8PostMethod postMethod = null; + + try { + postMethod = new Utf8PostMethod(client.getBaseUri() + DOCUMENT_URL + JSON_FORMAT); + postMethod.setParameter(FILE_ID, String.valueOf(fileId)); + + // remote request + postMethod.addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE); + + int status = client.executeMethod(postMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT); + + if (status == HttpStatus.SC_OK) { + String response = postMethod.getResponseBodyAsString(); + + // Parse the response + JSONObject respJSON = new JSONObject(response); + String url = respJSON.getJSONObject(NODE_OCS).getJSONObject(NODE_DATA).getString(NODE_URL); + + result = new RemoteOperationResult(true, postMethod); + result.setSingleData(url); + } else { + result = new RemoteOperationResult(false, postMethod); + client.exhaustResponse(postMethod.getResponseBodyAsStream()); + } + } catch (Exception e) { + result = new RemoteOperationResult(e); + Log_OC.e(TAG, "Get rich document url for file with id " + fileId + " failed: " + result.getLogMessage(), + result.getException()); + } finally { + if (postMethod != null) { + postMethod.releaseConnection(); + } + } + return result; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/SetFilesDownloadLimitOperation.kt b/app/src/main/java/com/owncloud/android/operations/SetFilesDownloadLimitOperation.kt new file mode 100644 index 000000000000..8918172c73de --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/SetFilesDownloadLimitOperation.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 ZetaTom <70907959+zetatom@users.noreply.github.com> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations + +import android.content.Context +import com.nextcloud.android.lib.resources.files.GetFilesDownloadLimitRemoteOperation +import com.nextcloud.android.lib.resources.files.RemoveFilesDownloadLimitRemoteOperation +import com.nextcloud.android.lib.resources.files.SetFilesDownloadLimitRemoteOperation +import com.nextcloud.utils.extensions.toNextcloudClient +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult + +class SetFilesDownloadLimitOperation( + private val shareId: Long, + private val newLimit: Int, + private val fileDataStorageManager: FileDataStorageManager, + private val context: Context +) : RemoteOperation() { + @Deprecated("Deprecated in Java") + override fun run(client: OwnCloudClient): RemoteOperationResult { + val nextcloudClient = client.toNextcloudClient(context) + val share = fileDataStorageManager.getShareById(shareId) + val token = share?.token ?: return RemoteOperationResult(RemoteOperationResult.ResultCode.SHARE_NOT_FOUND) + + val result = if (newLimit > 0) { + val operation = SetFilesDownloadLimitRemoteOperation(token, newLimit) + nextcloudClient.execute(operation) + } else { + val operation = RemoveFilesDownloadLimitRemoteOperation(token) + nextcloudClient.execute(operation) + } + + val path = share.path + if (result.isSuccess && path != null) { + val getFilesDownloadLimitRemoteOperation = GetFilesDownloadLimitRemoteOperation(path, false) + val remoteOperationResult = getFilesDownloadLimitRemoteOperation.execute(client) + + if (remoteOperationResult.isSuccess) { + share.fileDownloadLimit = remoteOperationResult.resultData.firstOrNull { updatedDownloadLimit -> + updatedDownloadLimit.token == share.token + } + fileDataStorageManager.saveShare(share) + } + } + + return result + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/SynchronizeFileOperation.java b/app/src/main/java/com/owncloud/android/operations/SynchronizeFileOperation.java new file mode 100644 index 000000000000..4aba8f2d5f63 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/SynchronizeFileOperation.java @@ -0,0 +1,349 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2013-2016 María Asensio Valverde + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-FileCopyrightText: 2012 Bartek Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; +import android.text.TextUtils; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.jobs.download.FileDownloadHelper; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.operations.common.SyncOperation; +import com.owncloud.android.ui.events.DialogEvent; +import com.owncloud.android.ui.events.DialogEventType; +import com.owncloud.android.utils.FileStorageUtils; + +import org.greenrobot.eventbus.EventBus; + +/** + * Remote operation performing the read of remote file in the ownCloud server. + */ +public class SynchronizeFileOperation extends SyncOperation { + + private static final String TAG = SynchronizeFileOperation.class.getSimpleName(); + + private OCFile mLocalFile; + private String mRemotePath; + private OCFile mServerFile; + private User mUser; + private boolean mSyncFileContents; + private Context mContext; + private boolean mTransferWasRequested; + private final boolean syncInBackgroundWorker; + private boolean postDialogEvent = true; + + + /** + * When 'false', uploads to the server are not done; only downloads or conflict detection. This is a temporal + * field. + * TODO Remove when 'folder synchronization' replaces 'folder download'. + */ + private boolean mAllowUploads; + + + /** + * Constructor for "full synchronization mode". + *

+ * Uses remotePath to retrieve all the data both in local cache and in the remote OC server when the operation is + * executed, instead of reusing {@link OCFile} instances. + *

+ * Useful for direct synchronization of a single file. + * + * @param remotePath remote path of the file + * @param user Nextcloud user owning the file. + * @param syncFileContents When 'true', transference of data will be started by the operation if needed and no + * conflict is detected. + * @param context Android context; needed to start transfers. + */ + public SynchronizeFileOperation( + String remotePath, + User user, + boolean syncFileContents, + Context context, + FileDataStorageManager storageManager, + boolean syncInBackgroundWorker, + boolean postDialogEvent) { + super(storageManager); + + mRemotePath = remotePath; + mLocalFile = null; + mServerFile = null; + mUser = user; + mSyncFileContents = syncFileContents; + mContext = context; + mAllowUploads = true; + this.syncInBackgroundWorker = syncInBackgroundWorker; + this.postDialogEvent = postDialogEvent; + } + + + /** + * Constructor allowing to reuse {@link OCFile} instances just queried from local cache or from remote OC server. + *

+ * Useful to include this operation as part of the synchronization of a folder (or a full account), avoiding the + * repetition of fetch operations (both in local database or remote server). + *

+ * At least one of localFile or serverFile MUST NOT BE NULL. If you don't have none of them, use the other + * constructor. + * + * @param localFile Data of file (just) retrieved from local cache/database. + * @param serverFile Data of file (just) retrieved from a remote server. If null, will be retrieved from + * network by the operation when executed. + * @param user Nextcloud user owning the file. + * @param syncFileContents When 'true', transference of data will be started by the operation if needed and no + * conflict is detected. + * @param context Android context; needed to start transfers. + */ + public SynchronizeFileOperation( + OCFile localFile, + OCFile serverFile, + User user, + boolean syncFileContents, + Context context, + FileDataStorageManager storageManager, + boolean syncInBackgroundWorker) { + super(storageManager); + + mLocalFile = localFile; + mServerFile = serverFile; + if (mLocalFile != null) { + mRemotePath = mLocalFile.getRemotePath(); + if (mServerFile != null && !mServerFile.getRemotePath().equals(mRemotePath)) { + throw new IllegalArgumentException("serverFile and localFile do not correspond" + + " to the same OC file"); + } + } else if (mServerFile != null) { + mRemotePath = mServerFile.getRemotePath(); + } else { + throw new IllegalArgumentException("Both serverFile and localFile are NULL"); + } + mUser = user; + mSyncFileContents = syncFileContents; + mContext = context; + mAllowUploads = true; + this.syncInBackgroundWorker = syncInBackgroundWorker; + } + + + /** + * Temporal constructor. + *

+ * Extends the previous one to allow constrained synchronizations where uploads are never performed - only downloads + * or conflict detection. + *

+ * Do not use unless you are involved in 'folder synchronization' or 'folder download' work in progress. + *

+ * TODO Remove when 'folder synchronization' replaces 'folder download'. + * + * @param localFile Data of file (just) retrieved from local cache/database. MUSTN't be null. + * @param serverFile Data of file (just) retrieved from a remote server. If null, will be retrieved from + * network by the operation when executed. + * @param user Nextcloud user owning the file. + * @param syncFileContents When 'true', transference of data will be started by the operation if needed and no + * conflict is detected. + * @param allowUploads When 'false', uploads to the server are not done; only downloads or conflict detection. + * @param context Android context; needed to start transfers. + */ + public SynchronizeFileOperation( + OCFile localFile, + OCFile serverFile, + User user, + boolean syncFileContents, + boolean allowUploads, + Context context, + FileDataStorageManager storageManager, + boolean syncInBackgroundWorker) { + this(localFile, serverFile, user, syncFileContents, context, storageManager, syncInBackgroundWorker); + mAllowUploads = allowUploads; + } + + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + + RemoteOperationResult result = null; + mTransferWasRequested = false; + + if (mLocalFile == null) { + // Get local file from the DB + mLocalFile = getStorageManager().getFileByPath(mRemotePath); + } + + if (!mLocalFile.isDown()) { + /// easy decision + requestForDownload(mLocalFile); + result = new RemoteOperationResult(ResultCode.OK); + } else { + /// local copy in the device -> need to think a bit more before do anything + if (mServerFile == null) { + ReadFileRemoteOperation operation = new ReadFileRemoteOperation(mRemotePath); + result = operation.execute(client); + + if (result.isSuccess()) { + mServerFile = FileStorageUtils.fillOCFile((RemoteFile) result.getData().get(0)); + mServerFile.setLastSyncDateForProperties(System.currentTimeMillis()); + } else if (result.getCode() != ResultCode.FILE_NOT_FOUND) { + return result; + } + } + + if (mServerFile != null) { + /// check changes in server and local file + boolean serverChanged; + if (TextUtils.isEmpty(mLocalFile.getEtag())) { + // file uploaded (null) or downloaded ("") before upgrade to version 1.8.0; check the old condition + serverChanged = mServerFile.getModificationTimestamp() != + mLocalFile.getModificationTimestampAtLastSyncForData(); + } else { + serverChanged = !mServerFile.getEtag().equals(mLocalFile.getEtag()); + } + boolean localChanged = + mLocalFile.getLocalModificationTimestamp() > mLocalFile.getLastSyncDateForData(); + + /// decide action to perform depending upon changes + //if (!mLocalFile.getEtag().isEmpty() && localChanged && serverChanged) { + if (localChanged && serverChanged) { + result = new RemoteOperationResult(ResultCode.SYNC_CONFLICT); + getStorageManager().saveConflict(mLocalFile, mServerFile.getEtag()); + + } else if (localChanged) { + if (mSyncFileContents && mAllowUploads) { + requestForUpload(mLocalFile); + // the local update of file properties will be done by the FileUploader + // service when the upload finishes + } else { + // NOTHING TO DO HERE: updating the properties of the file in the server + // without uploading the contents would be stupid; + // So, an instance of SynchronizeFileOperation created with + // syncFileContents == false is completely useless when we suspect + // that an upload is necessary (for instance, in FileObserverService). + Log_OC.d(TAG, "Nothing to do here"); + } + result = new RemoteOperationResult(ResultCode.OK); + + } else if (serverChanged) { + mLocalFile.setRemoteId(mServerFile.getRemoteId()); + + if (mSyncFileContents) { + requestForDownload(mLocalFile); // local, not server; we won't to keep + // the value of favorite! + // the update of local data will be done later by the FileUploader + // service when the upload finishes + } else { + // TODO CHECK: is this really useful in some point in the code? + mServerFile.setFavorite(mLocalFile.isFavorite()); + mServerFile.setHidden(mLocalFile.shouldHide()); + mServerFile.setLastSyncDateForData(mLocalFile.getLastSyncDateForData()); + mServerFile.setStoragePath(mLocalFile.getStoragePath()); + mServerFile.setParentId(mLocalFile.getParentId()); + mServerFile.setEtag(mLocalFile.getEtag()); + getStorageManager().saveFile(mServerFile); + + } + result = new RemoteOperationResult(ResultCode.OK); + + } else { + // nothing changed, nothing to do + result = new RemoteOperationResult(ResultCode.OK); + } + + // safe blanket: sync'ing a not in-conflict file will clean wrong conflict markers in ancestors + if (result.getCode() != ResultCode.SYNC_CONFLICT) { + getStorageManager().saveConflict(mLocalFile, null); + } + } else { + // remote file does not exist, deleting local copy + boolean deleteResult = getStorageManager().removeFile(mLocalFile, true, true); + + if (deleteResult) { + result = new RemoteOperationResult(ResultCode.FILE_NOT_FOUND); + } else { + Log_OC.e(TAG, "Removal of local copy failed (remote file does not exist any longer)."); + } + } + + } + + Log_OC.i(TAG, "Synchronizing " + mUser.getAccountName() + ", file " + mLocalFile.getRemotePath() + + ": " + result.getLogMessage()); + + if (postDialogEvent) { + EventBus.getDefault().post(new DialogEvent(DialogEventType.SYNC)); + } + return result; + } + + + /** + * Requests for an upload to the FileUploader service + * + * @param file OCFile object representing the file to upload + */ + private void requestForUpload(OCFile file) { + FileUploadHelper.Companion.instance().uploadUpdatedFile( + mUser, + new OCFile[]{ file }, + FileUploadWorker.LOCAL_BEHAVIOUR_MOVE, + NameCollisionPolicy.OVERWRITE); + + mTransferWasRequested = true; + } + + private void requestForDownload(OCFile file) { + final var fileDownloadHelper = FileDownloadHelper.Companion.instance(); + final var filename = file.getFileName(); + + if (syncInBackgroundWorker) { + Log_OC.d(TAG, "downloading file without notification: " + filename); + + try { + final var operation = new DownloadFileOperation(mUser, file, mContext); + final var result = operation.execute(getClient()); + + mTransferWasRequested = true; + + if (result.isSuccess()) { + fileDownloadHelper.saveFile(file, operation, getStorageManager()); + Log_OC.d(TAG, "requestForDownload completed for: " + filename); + } else { + Log_OC.d(TAG, "requestForDownload failed for: " + filename); + } + } catch (Exception e) { + Log_OC.d(TAG, "Exception caught at requestForDownload" + e); + } + } else { + Log_OC.d(TAG, "downloading file with notification: " + filename); + mTransferWasRequested = true; + fileDownloadHelper.downloadFile(mUser, file); + } + } + + public boolean transferWasRequested() { + return mTransferWasRequested; + } + + public OCFile getLocalFile() { + return mLocalFile; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java new file mode 100644 index 000000000000..9ffde49006c7 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java @@ -0,0 +1,595 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2012-2013 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; +import android.content.Intent; +import android.text.TextUtils; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.jobs.download.FileDownloadHelper; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.OperationCancelledException; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation; +import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.operations.common.SyncOperation; +import com.owncloud.android.services.OperationsService; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.MimeTypeUtil; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Vector; +import java.util.concurrent.atomic.AtomicBoolean; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * Remote operation performing the synchronization of the list of files contained + * in a folder identified with its remote path. + * Fetches the list and properties of the files contained in the given folder, including their + * properties, and updates the local database with them. + * Does NOT enter in the child folders to synchronize their contents also, BUT requests for a new operation instance + * doing so. + */ +public class SynchronizeFolderOperation extends SyncOperation { + + private static final String TAG = SynchronizeFolderOperation.class.getSimpleName(); + + /** Remote path of the folder to synchronize */ + private String mRemotePath; + + /** Account where the file to synchronize belongs */ + private User user; + + /** Android context; necessary to send requests to the download service */ + private Context mContext; + + /** Locally cached information about folder to synchronize */ + private OCFile mLocalFolder; + + /** Counter of conflicts found between local and remote files */ + private int mConflictsFound; + + /** Counter of failed operations in synchronization of kept-in-sync files */ + private int mFailsInFileSyncsFound; + + /** + * 'True' means that the remote folder changed and should be fetched + */ + private boolean mRemoteFolderChanged; + + private List mFilesForDirectDownload; + // to avoid extra PROPFINDs when there was no change in the folder + + private List mFilesToSyncContents; + // this will be used for every file when 'folder synchronization' replaces 'folder download' + + private final AtomicBoolean mCancellationRequested; + + private final boolean syncInBackgroundWorker; + + /** + * Creates a new instance of {@link SynchronizeFolderOperation}. + * + * @param context Application context. + * @param remotePath Path to synchronize. + * @param user Nextcloud account where the folder is located. + */ + public SynchronizeFolderOperation(Context context, + String remotePath, + User user, + FileDataStorageManager storageManager, + boolean syncInBackgroundWorker) { + super(storageManager); + + mRemotePath = remotePath; + this.user = user; + mContext = context; + mRemoteFolderChanged = false; + mFilesForDirectDownload = new Vector<>(); + mFilesToSyncContents = new Vector<>(); + mCancellationRequested = new AtomicBoolean(false); + this.syncInBackgroundWorker = syncInBackgroundWorker; + } + + + /** + * Performs the synchronization. + * + * {@inheritDoc} + */ + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + mFailsInFileSyncsFound = 0; + mConflictsFound = 0; + + try { + // get locally cached information about folder + mLocalFolder = getStorageManager().getFileByPath(mRemotePath); + if (mLocalFolder == null) { + Log_OC.e(TAG, "Local folder is null, cannot run synchronize folder operation, remote path: " + mRemotePath); + return new RemoteOperationResult<>(ResultCode.FILE_NOT_FOUND); + } + + result = checkForChanges(client); + + if (result.isSuccess()) { + if (mRemoteFolderChanged) { + result = fetchAndSyncRemoteFolder(client); + } else { + prepareOpsFromLocalKnowledge(); + } + + if (result.isSuccess()) { + syncContents(client); + } + } + + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + + } catch (OperationCancelledException e) { + result = new RemoteOperationResult(e); + } + + return result; + } + + private RemoteOperationResult checkForChanges(OwnCloudClient client) throws OperationCancelledException { + Log_OC.d(TAG, "Checking changes in " + user.getAccountName() + mRemotePath); + + mRemoteFolderChanged = true; + + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + + // remote request + ReadFileRemoteOperation operation = new ReadFileRemoteOperation(mRemotePath); + var result = operation.execute(client); + if (result.isSuccess() && result.getData().get(0) instanceof RemoteFile remoteFile) { + OCFile remoteFolder = FileStorageUtils.fillOCFile(remoteFile); + + // check if remote and local folder are different + mRemoteFolderChanged = !(remoteFolder.getEtag().equalsIgnoreCase(mLocalFolder.getEtag())); + + result = new RemoteOperationResult<>(ResultCode.OK); + + Log_OC.i(TAG, "Checked " + user.getAccountName() + mRemotePath + " : " + + (mRemoteFolderChanged ? "changed" : "not changed")); + } else { + // check failed + if (result.getCode() == ResultCode.FILE_NOT_FOUND) { + removeLocalFolder(); + } + if (result.isException()) { + Log_OC.e(TAG, "Checked " + user.getAccountName() + mRemotePath + " : " + + result.getLogMessage(), result.getException()); + } else { + Log_OC.e(TAG, "Checked " + user.getAccountName() + mRemotePath + " : " + + result.getLogMessage()); + } + + } + + return result; + } + + + private RemoteOperationResult fetchAndSyncRemoteFolder(OwnCloudClient client) throws OperationCancelledException { + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + + ReadFolderRemoteOperation operation = new ReadFolderRemoteOperation(mRemotePath); + RemoteOperationResult result = operation.execute(client); + Log_OC.d(TAG, "Synchronizing " + user.getAccountName() + mRemotePath); + Log_OC.d(TAG, "Synchronizing remote id" + mLocalFolder.getRemoteId()); + + if (result.isSuccess()) { + synchronizeData(result.getData()); + if (mConflictsFound > 0 || mFailsInFileSyncsFound > 0) { + result = new RemoteOperationResult(ResultCode.SYNC_CONFLICT); + // should be a different result code, but will do the job + } + } else { + if (result.getCode() == ResultCode.FILE_NOT_FOUND) { + removeLocalFolder(); + } + } + + return result; + } + + + private void removeLocalFolder() { + FileDataStorageManager storageManager = getStorageManager(); + if (storageManager.fileExists(mLocalFolder.getFileId())) { + String currentSavePath = FileStorageUtils.getSavePath(user.getAccountName()); + storageManager.removeFolder( + mLocalFolder, + true, + mLocalFolder.isDown() // TODO: debug, I think this is always false for folders + && mLocalFolder.getStoragePath().startsWith(currentSavePath) + ); + } + } + + + /** + * Synchronizes the data retrieved from the server about the contents of the target folder + * with the current data in the local database. + * + * @param folderAndFiles Remote folder and children files in Folder + */ + private void synchronizeData(List folderAndFiles) throws OperationCancelledException { + + + // parse data from remote folder + OCFile remoteFolder = FileStorageUtils.fillOCFile((RemoteFile) folderAndFiles.get(0)); + remoteFolder.setParentId(mLocalFolder.getParentId()); + remoteFolder.setFileId(mLocalFolder.getFileId()); + + Log_OC.d(TAG, "Remote folder " + mLocalFolder.getRemotePath() + " changed - starting update of local data "); + + mFilesForDirectDownload.clear(); + mFilesToSyncContents.clear(); + + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + + FileDataStorageManager storageManager = getStorageManager(); + + // if local folder is encrypted, download fresh metadata + boolean encryptedAncestor = FileStorageUtils.checkEncryptionStatus(remoteFolder, storageManager); + mLocalFolder.setEncrypted(encryptedAncestor); + + // update permission + mLocalFolder.setPermissions(remoteFolder.getPermissions()); + + // update richWorkspace + mLocalFolder.setRichWorkspace(remoteFolder.getRichWorkspace()); + + Object object = RefreshFolderOperation.getDecryptedFolderMetadata(encryptedAncestor, + mLocalFolder, + getClient(), + user, + mContext); + if (mLocalFolder.isEncrypted() && object == null) { + throw new IllegalStateException("metadata is null!"); + } + + // get current data about local contents of the folder to synchronize + Map localFilesMap = RefreshFolderOperation.prefillLocalFilesMap(object,storageManager.getFolderContent(mLocalFolder, false)); + + // loop to synchronize every child + List updatedFiles = new ArrayList<>(folderAndFiles.size() - 1); + OCFile remoteFile; + OCFile localFile; + OCFile updatedFile; + RemoteFile remote; + + for (int i = 1; i < folderAndFiles.size(); i++) { + /// new OCFile instance with the data from the server + remote = (RemoteFile) folderAndFiles.get(i); + remoteFile = FileStorageUtils.fillOCFile(remote); + + /// new OCFile instance to merge fresh data from server with local state + updatedFile = FileStorageUtils.fillOCFile(remote); + updatedFile.setParentId(mLocalFolder.getFileId()); + + /// retrieve local data for the read file + localFile = localFilesMap.remove(remoteFile.getRemotePath()); + + // TODO better implementation is needed + if (localFile == null) { + localFile = storageManager.getFileByPath(updatedFile.getRemotePath()); + } + + /// add to updatedFile data about LOCAL STATE (not existing in server) + updateLocalStateData(remoteFile, localFile, updatedFile); + + /// check and fix, if needed, local storage path + FileStorageUtils.searchForLocalFileInDefaultPath(updatedFile, user.getAccountName()); + + // update file name for encrypted files + if (object instanceof DecryptedFolderMetadataFileV1 metadataFile) { + RefreshFolderOperation.updateFileNameForEncryptedFileV1(storageManager, metadataFile, updatedFile); + } else if (object instanceof DecryptedFolderMetadataFile metadataFile) { + RefreshFolderOperation.updateFileNameForEncryptedFile(storageManager, metadataFile, updatedFile); + } + + // we parse content, so either the folder itself or its direct parent (which we check) must be encrypted + boolean encrypted = updatedFile.isEncrypted() || mLocalFolder.isEncrypted(); + updatedFile.setEncrypted(encrypted); + + /// classify file to sync/download contents later + classifyFileForLaterSyncOrDownload(remoteFile, localFile); + + updatedFiles.add(updatedFile); + } + + // update file name for encrypted files + if (object instanceof DecryptedFolderMetadataFileV1 metadataFile) { + RefreshFolderOperation.updateFileNameForEncryptedFileV1(storageManager, metadataFile, mLocalFolder); + } else if (object instanceof DecryptedFolderMetadataFile metadataFile) { + RefreshFolderOperation.updateFileNameForEncryptedFile(storageManager, metadataFile, mLocalFolder); + } + + // save updated contents in local database + storageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values()); + mLocalFolder.setLastSyncDateForData(System.currentTimeMillis()); + storageManager.saveFile(mLocalFolder); + } + + private void updateLocalStateData(OCFile remoteFile, OCFile localFile, OCFile updatedFile) { + updatedFile.setLastSyncDateForProperties(System.currentTimeMillis()); + if (localFile != null) { + updatedFile.setFileId(localFile.getFileId()); + updatedFile.setLastSyncDateForData(localFile.getLastSyncDateForData()); + updatedFile.setModificationTimestampAtLastSyncForData( + localFile.getModificationTimestampAtLastSyncForData() + ); + updatedFile.setStoragePath(localFile.getStoragePath()); + // eTag will not be updated unless file CONTENTS are synchronized + updatedFile.setEtag(localFile.getEtag()); + if (updatedFile.isFolder()) { + updatedFile.setFileLength(localFile.getFileLength()); + // TODO move operations about size of folders to FileContentProvider + } else if (mRemoteFolderChanged && MimeTypeUtil.isImage(remoteFile) && + remoteFile.getModificationTimestamp() != + localFile.getModificationTimestamp()) { + updatedFile.setUpdateThumbnailNeeded(true); + Log_OC.d(TAG, "Image " + remoteFile.getFileName() + " updated on the server"); + } + updatedFile.setSharedViaLink(localFile.isSharedViaLink()); + updatedFile.setSharedWithSharee(localFile.isSharedWithSharee()); + updatedFile.setEtagInConflict(localFile.getEtagInConflict()); + } else { + // remote eTag will not be updated unless file CONTENTS are synchronized + updatedFile.setEtag(""); + } + } + + @SuppressFBWarnings("JLM") + private void classifyFileForLaterSyncOrDownload(OCFile remoteFile, OCFile localFile) throws OperationCancelledException { + if (remoteFile.isFolder()) { + /// to download children files recursively + synchronized (mCancellationRequested) { + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + startSyncFolderOperation(remoteFile.getRemotePath()); + } + + } else { + /// prepare content synchronization for files (any file, not just favorites) + SynchronizeFileOperation operation = new SynchronizeFileOperation( + localFile, + remoteFile, + user, + true, + mContext, + getStorageManager(), + syncInBackgroundWorker + ); + mFilesToSyncContents.add(operation); + } + } + + + private void prepareOpsFromLocalKnowledge() throws OperationCancelledException { + List children = getStorageManager().getFolderContent(mLocalFolder, false); + for (OCFile child : children) { + if (!child.isFolder()) { + if (!child.isDown()) { + mFilesForDirectDownload.add(child); + } else { + /// this should result in direct upload of files that were locally modified + SynchronizeFileOperation operation = new SynchronizeFileOperation( + child, + child.getEtagInConflict() != null ? child : null, + user, + true, + mContext, + getStorageManager(), + syncInBackgroundWorker + ); + mFilesToSyncContents.add(operation); + } + } + } + } + + private void syncContents(OwnCloudClient client) throws OperationCancelledException { + startDirectDownloads(); + startContentSynchronizations(mFilesToSyncContents); + updateETag(client); + } + + /** + * Updates the eTag of the local folder after a successful synchronization. + * This ensures that any changes to local files, which may alter the eTag, are correctly reflected. + * + * @param client the OwnCloudClient instance used to execute remote operations. + */ + private void updateETag(OwnCloudClient client) { + ReadFolderRemoteOperation operation = new ReadFolderRemoteOperation(mRemotePath); + final var result = operation.execute(client); + if (!result.isSuccess()) { + Log_OC.w(TAG, "Cannot update eTag, read folder operation is failed"); + return; + } + + if (result.getData().get(0) instanceof RemoteFile remoteFile) { + String eTag = remoteFile.getEtag(); + mLocalFolder.setEtag(eTag); + + final FileDataStorageManager storageManager = getStorageManager(); + storageManager.saveFile(mLocalFolder); + } + } + + private void startDirectDownloads() { + final var fileDownloadHelper = FileDownloadHelper.Companion.instance(); + + if (syncInBackgroundWorker) { + try { + for (OCFile file: mFilesForDirectDownload) { + synchronized (mCancellationRequested) { + if (mCancellationRequested.get()) { + break; + } + } + + if (file == null) { + continue; + } + + final var operation = new DownloadFileOperation(user, file, mContext); + var result = operation.execute(getClient()); + + String filename = file.getFileName(); + if (filename == null) { + continue; + } + + if (result.isSuccess()) { + fileDownloadHelper.saveFile(file, operation, getStorageManager()); + Log_OC.d(TAG, "startDirectDownloads completed for: " + file.getFileName()); + } else { + Log_OC.d(TAG, "startDirectDownloads failed for: " + file.getFileName()); + } + } + } catch (Exception e) { + Log_OC.d(TAG, "Exception caught at startDirectDownloads" + e); + } + } else { + fileDownloadHelper.downloadFolder(mLocalFolder, user.getAccountName()); + } + } + + /** + * Performs a list of synchronization operations, determining if a download or upload is needed + * or if exists conflict due to changes both in local and remote contents of the each file. + * + * If download or upload is needed, request the operation to the corresponding service and goes on. + * + * @param filesToSyncContents Synchronization operations to execute. + */ + private void startContentSynchronizations(List filesToSyncContents) + throws OperationCancelledException { + + Log_OC.v(TAG, "Starting content synchronization... "); + RemoteOperationResult contentsResult; + for (SyncOperation op: filesToSyncContents) { + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + contentsResult = op.execute(mContext); + if (!contentsResult.isSuccess()) { + if (contentsResult.getCode() == ResultCode.SYNC_CONFLICT) { + mConflictsFound++; + } else { + mFailsInFileSyncsFound++; + if (contentsResult.getException() != null) { + Log_OC.e(TAG, "Error while synchronizing file : " + + contentsResult.getLogMessage(), contentsResult.getException()); + } else { + Log_OC.e(TAG, "Error while synchronizing file : " + + contentsResult.getLogMessage()); + } + } + // TODO - use the errors count in notifications + } // won't let these fails break the synchronization process + } + } + + /** + * Scans the default location for saving local copies of files searching for + * a 'lost' file with the same full name as the {@link com.owncloud.android.datamodel.OCFile} + * received as parameter. + * + * @param file File to associate a possible 'lost' local file. + */ + private void searchForLocalFileInDefaultPath(OCFile file) { + if (file.getStoragePath() == null && !file.isFolder()) { + File f = new File(FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), file)); + if (f.exists()) { + file.setStoragePath(f.getAbsolutePath()); + file.setLastSyncDateForData(f.lastModified()); + } + } + } + + + /** + * Cancel operation + */ + public void cancel() { + mCancellationRequested.set(true); + } + + public Optional getFolderNameFromPath() { + if (mLocalFolder == null) { + return Optional.empty(); + } + + String path = mLocalFolder.getStoragePath(); + if (!TextUtils.isEmpty(path)) { + File folder = new File(path); + return Optional.of(folder.getName()); + } + + String filepath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mLocalFolder); + File folder = new File(filepath); + return Optional.of(folder.getName()); + } + + private void startSyncFolderOperation(String path){ + Intent intent = new Intent(mContext, OperationsService.class); + intent.setAction(OperationsService.ACTION_SYNC_FOLDER); + intent.putExtra(OperationsService.EXTRA_ACCOUNT, user.toPlatformAccount()); + intent.putExtra(OperationsService.EXTRA_REMOTE_PATH, path); + mContext.startService(intent); + } + + public String getRemotePath() { + return mRemotePath; + } + + public String getAccountName() { + return user.getAccountName(); + } + + public Long getFolderId() { + if (mLocalFolder == null) { + return null; + } + + return mLocalFolder.getFileId(); + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/UnshareOperation.java b/app/src/main/java/com/owncloud/android/operations/UnshareOperation.java new file mode 100644 index 000000000000..3735703ea9f9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/UnshareOperation.java @@ -0,0 +1,163 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.content.Context; + +import com.nextcloud.client.account.User; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.RemoveShareRemoteOperation; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.operations.common.SyncOperation; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.EncryptionUtilsV2; + +import java.util.List; + +/** + * Unshare file/folder Save the data in Database + */ +public class UnshareOperation extends SyncOperation { + + private static final String TAG = UnshareOperation.class.getSimpleName(); + private static final int SINGLY_SHARED = 1; + + private final String remotePath; + private final long shareId; + private final Context context; + private final User user; + + public UnshareOperation(String remotePath, + long shareId, + FileDataStorageManager storageManager, + User user, + Context context) { + super(storageManager); + + this.remotePath = remotePath; + this.shareId = shareId; + this.user = user; + this.context = context; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + RemoteOperationResult result; + String token = null; + + // Get Share for a file + OCShare share = getStorageManager().getShareById(shareId); + + if (share != null) { + OCFile file = getStorageManager().getFileByEncryptedRemotePath(remotePath); + + if (file.isEncrypted() && share.getShareType() != ShareType.PUBLIC_LINK) { + // E2E: lock folder + try { + token = EncryptionUtils.lockFolder(file, client, file.getE2eCounter() + 1); + } catch (UploadException e) { + return new RemoteOperationResult(e); + } + + // download metadata + Object object = EncryptionUtils.downloadFolderMetadata(file, + client, + context, + user); + + if (object == null) { + return new RemoteOperationResult(new RuntimeException("No metadata!")); + } + + if (object instanceof DecryptedFolderMetadataFileV1) { + throw new RuntimeException("Trying to unshare on e2e v1!"); + } + + DecryptedFolderMetadataFile metadata = (DecryptedFolderMetadataFile) object; + + // remove sharee from metadata + EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); + DecryptedFolderMetadataFile newMetadata = encryptionUtilsV2.removeShareeFromMetadata(metadata, + share.getShareWith()); + + // upload metadata + try { + encryptionUtilsV2.serializeAndUploadMetadata(file, + newMetadata, + token, + client, + true, + context, + user, + getStorageManager()); + } catch (UploadException e) { + return new RemoteOperationResult(new RuntimeException("Upload of metadata failed!")); + } + } + + RemoveShareRemoteOperation operation = new RemoveShareRemoteOperation(share.getRemoteId()); + result = operation.execute(client); + boolean isFileExists = existsFile(client, file.getRemotePath()); + boolean isShareExists = getStorageManager().getShareById(shareId) != null; + + if (result.isSuccess()) { + // E2E: unlock folder + if (file.isEncrypted() && share.getShareType() != ShareType.PUBLIC_LINK) { + RemoteOperationResult unlockResult = EncryptionUtils.unlockFolder(file, client, token); + if (!unlockResult.isSuccess()) { + return new RemoteOperationResult<>(new RuntimeException("Unlock failed")); + } + } + + Log_OC.d(TAG, "Share id = " + share.getRemoteId() + " deleted"); + + if (ShareType.PUBLIC_LINK == share.getShareType()) { + file.setSharedViaLink(false); + } else if (ShareType.USER == share.getShareType() || ShareType.GROUP == share.getShareType() + || ShareType.FEDERATED == share.getShareType() || ShareType.FEDERATED_GROUP == share.getShareType()) { + // Check if it is the last share + List sharesWith = getStorageManager(). + getSharesWithForAFile(remotePath, + getStorageManager().getUser().getAccountName()); + if (sharesWith.size() == SINGLY_SHARED) { + file.setSharedWithSharee(false); + } + } + + getStorageManager().saveFile(file); + getStorageManager().removeShare(share); + } else if (result.getCode() != ResultCode.MAINTENANCE_MODE && !isFileExists) { + // UnShare failed because file was deleted before + getStorageManager().removeFile(file, true, true); + } else if (isShareExists && result.getCode() == ResultCode.FILE_NOT_FOUND) { + // UnShare failed because share was deleted before + getStorageManager().removeShare(share); + } + + } else { + result = new RemoteOperationResult(ResultCode.SHARE_NOT_FOUND); + } + + return result; + } + + private boolean existsFile(OwnCloudClient client, String remotePath) { + return new ExistenceCheckRemoteOperation(remotePath, false).execute(client).isSuccess(); + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/UpdateNoteForShareOperation.java b/app/src/main/java/com/owncloud/android/operations/UpdateNoteForShareOperation.java new file mode 100644 index 000000000000..d10b5e3d973f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/UpdateNoteForShareOperation.java @@ -0,0 +1,59 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.shares.GetShareRemoteOperation; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.UpdateShareRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; + + +/** + * Updates a note of a private share. + */ +public class UpdateNoteForShareOperation extends SyncOperation { + + private final long shareId; + private final String note; + + public UpdateNoteForShareOperation(long shareId, String note, FileDataStorageManager storageManager) { + super(storageManager); + + this.shareId = shareId; + this.note = note; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + + OCShare share = getStorageManager().getShareById(shareId); + + if (share == null) { + return new RemoteOperationResult(RemoteOperationResult.ResultCode.SHARE_NOT_FOUND); + } + + UpdateShareRemoteOperation updateOperation = new UpdateShareRemoteOperation(share.getRemoteId()); + updateOperation.setNote(note); + RemoteOperationResult result = updateOperation.execute(client); + + if (result.isSuccess()) { + RemoteOperation getShareOp = new GetShareRemoteOperation(share.getRemoteId()); + result = getShareOp.execute(client); + if (result.isSuccess()) { + getStorageManager().saveShare((OCShare) result.getData().get(0)); + } + } + + return result; + } +} + diff --git a/src/main/java/com/owncloud/android/operations/UpdateOCVersionOperation.java b/app/src/main/java/com/owncloud/android/operations/UpdateOCVersionOperation.java similarity index 76% rename from src/main/java/com/owncloud/android/operations/UpdateOCVersionOperation.java rename to app/src/main/java/com/owncloud/android/operations/UpdateOCVersionOperation.java index f1e0414009f9..e8f9fc114a7f 100644 --- a/src/main/java/com/owncloud/android/operations/UpdateOCVersionOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UpdateOCVersionOperation.java @@ -1,29 +1,19 @@ -/** - * ownCloud Android client application - * - * @author David A. Velasco - * Copyright (C) 2015 ownCloud Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . +/* + * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2022 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ - package com.owncloud.android.operations; -import android.accounts.Account; import android.accounts.AccountManager; import android.content.Context; +import com.nextcloud.client.account.User; import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.accounts.AccountUtils.Constants; import com.owncloud.android.lib.common.operations.RemoteOperation; @@ -37,7 +27,6 @@ import org.json.JSONException; import org.json.JSONObject; - /** * Remote operation that checks the version of an ownCloud server and stores it locally */ @@ -47,13 +36,12 @@ public class UpdateOCVersionOperation extends RemoteOperation { private static final String STATUS_PATH = "/status.php"; - private Account mAccount; + private final User user; private Context mContext; private OwnCloudVersion mOwnCloudVersion; - - - public UpdateOCVersionOperation(Account account, Context context) { - mAccount = account; + + public UpdateOCVersionOperation(User user, Context context) { + this.user = user; mContext = context; mOwnCloudVersion = null; } @@ -62,12 +50,12 @@ public UpdateOCVersionOperation(Account account, Context context) { @Override protected RemoteOperationResult run(OwnCloudClient client) { AccountManager accountMngr = AccountManager.get(mContext); - String statUrl = accountMngr.getUserData(mAccount, Constants.KEY_OC_BASE_URL); + String statUrl = accountMngr.getUserData(user.toPlatformAccount(), Constants.KEY_OC_BASE_URL); statUrl += STATUS_PATH; RemoteOperationResult result = null; GetMethod getMethod = null; - String webDav = client.getWebdavUri().toString(); + String webDav = client.getFilesDavUri().toString(); try { getMethod = new GetMethod(statUrl); @@ -85,8 +73,8 @@ protected RemoteOperationResult run(OwnCloudClient client) { String version = json.getString("version"); mOwnCloudVersion = new OwnCloudVersion(version); if (mOwnCloudVersion.isVersionValid()) { - accountMngr.setUserData(mAccount, Constants.KEY_OC_VERSION, mOwnCloudVersion.getVersion()); - Log_OC.d(TAG, "Got new OC version " + mOwnCloudVersion.toString()); + accountMngr.setUserData(user.toPlatformAccount(), Constants.KEY_OC_VERSION, mOwnCloudVersion.getVersion()); + Log_OC.d(TAG, "Got new OC version " + mOwnCloudVersion); result = new RemoteOperationResult(ResultCode.OK); diff --git a/app/src/main/java/com/owncloud/android/operations/UpdateShareInfoOperation.java b/app/src/main/java/com/owncloud/android/operations/UpdateShareInfoOperation.java new file mode 100644 index 000000000000..3d219893f10f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/UpdateShareInfoOperation.java @@ -0,0 +1,161 @@ +/* + * Nextcloud Android client application + * + * @author TSI-mc + * Copyright (C) 2021 TSI-mc + * Copyright (C) 2021 Nextcloud GmbH + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.operations; + +import android.text.TextUtils; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.shares.GetShareRemoteOperation; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.UpdateShareRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; + + +/** + * Updates an existing private share for a given file. + */ +public class UpdateShareInfoOperation extends SyncOperation { + + private OCShare share; + private long shareId; + private long shareRemoteId; + private long expirationDateInMillis; + private String note; + private boolean hideFileDownload; + private int permissions = -1; + private String password; + private String label; + private String attributes; + + private static final String TAG = "UpdateShareInfoOperation"; + + /** + * Constructor + * + * @param share {@link OCShare} to update. Mandatory argument + *

+ * this will be triggered while creating new share + */ + public UpdateShareInfoOperation(OCShare share, FileDataStorageManager storageManager) { + super(storageManager); + + this.share = share; + expirationDateInMillis = 0L; + note = null; + } + + /** + * Constructor + * + * @param shareId {@link OCShare} to update. Mandatory argument + *

+ * this will be triggered while modifying existing share + */ + public UpdateShareInfoOperation(long shareId, long shareRemoteId, FileDataStorageManager storageManager) { + super(storageManager); + + this.shareRemoteId = shareRemoteId; + this.shareId = shareId; + expirationDateInMillis = 0L; + note = null; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + + OCShare share; + if (shareId > 0) { + share = getStorageManager().getShareById(shareId); + } else { + share = this.share; + } + + if (share == null && shareRemoteId > 0) { + Log_OC.w(TAG,"share is null, trying to fetch"); + final var shareRemoteOperation = new GetShareRemoteOperation(shareRemoteId); + final var result = shareRemoteOperation.execute(client); + if (result.isSuccess()) { + share = (OCShare) result.getData().get(0); + } + } + + if (share == null) { + Log_OC.e(TAG,"share is null, fetching operation is failed"); + return new RemoteOperationResult<>(RemoteOperationResult.ResultCode.SHARE_NOT_FOUND); + } + + // Update remote share + UpdateShareRemoteOperation updateOp = new UpdateShareRemoteOperation(share.getRemoteId()); + updateOp.setExpirationDate(expirationDateInMillis); + updateOp.setHideFileDownload(hideFileDownload); + if (!TextUtils.isEmpty(note)) { + updateOp.setNote(note); + } + if (permissions > -1) { + updateOp.setPermissions(permissions); + } + updateOp.setPassword(password); + updateOp.setLabel(label); + updateOp.setAttributes(attributes); + + var result = updateOp.execute(client); + + if (result.isSuccess()) { + final var getShareOp = new GetShareRemoteOperation(share.getRemoteId()); + result = getShareOp.execute(client); + + //only update the share in storage if shareId is available + //this will be triggered by editing existing share + if (result.isSuccess() && shareId > 0) { + OCShare ocShare = (OCShare) result.getData().get(0); + ocShare.setPasswordProtected(!TextUtils.isEmpty(password)); + ocShare.setRemoteId(shareRemoteId); + ocShare.setId(shareId); + getStorageManager().saveShare(ocShare); + } + } + + return result; + } + + public void setExpirationDateInMillis(long expirationDateInMillis) { + this.expirationDateInMillis = expirationDateInMillis; + } + + public void setNote(String note) { + this.note = note; + } + + public void setHideFileDownload(boolean hideFileDownload) { + this.hideFileDownload = hideFileDownload; + } + + public void setAttributes(String attributes) { + this.attributes = attributes; + } + + public void setPermissions(int permissions) { + this.permissions = permissions; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setLabel(String label) { + this.label = label; + } +} + diff --git a/app/src/main/java/com/owncloud/android/operations/UpdateSharePermissionsOperation.java b/app/src/main/java/com/owncloud/android/operations/UpdateSharePermissionsOperation.java new file mode 100644 index 000000000000..aef74794354b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/UpdateSharePermissionsOperation.java @@ -0,0 +1,110 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.text.TextUtils; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.FileUtils; +import com.owncloud.android.lib.resources.shares.GetShareRemoteOperation; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.UpdateShareRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; + +/** + * Updates an existing private share for a given file. + */ +public class UpdateSharePermissionsOperation extends SyncOperation { + + private final long shareId; + private int permissions; + private long expirationDateInMillis; + private String password; + private String path; + + /** + * Constructor + * + * @param shareId Private {@link OCShare} to update. Mandatory argument + */ + public UpdateSharePermissionsOperation(long shareId, FileDataStorageManager storageManager) { + super(storageManager); + + this.shareId = shareId; + permissions = -1; + expirationDateInMillis = 0L; + password = null; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + + OCShare share = getStorageManager().getShareById(shareId); // ShareType.USER | ShareType.GROUP | ShareType.FEDERATED_GROUP + + if (share == null) { + // TODO try to get remote share before failing? + return new RemoteOperationResult(RemoteOperationResult.ResultCode.SHARE_NOT_FOUND); + } + + path = share.getPath(); + + // Update remote share with password + UpdateShareRemoteOperation updateOp = new UpdateShareRemoteOperation(share.getRemoteId()); + updateOp.setPassword(password); + updateOp.setPermissions(permissions); + updateOp.setExpirationDate(expirationDateInMillis); + RemoteOperationResult result = updateOp.execute(client); + + if (result.isSuccess()) { + RemoteOperation getShareOp = new GetShareRemoteOperation(share.getRemoteId()); + result = getShareOp.execute(client); + if (result.isSuccess()) { + share = (OCShare) result.getData().get(0); + // TODO check permissions are being saved + updateData(share); + } + } + + return result; + } + + private void updateData(OCShare share) { + // Update DB with the response + share.setPath(path); // TODO - check if may be moved to UpdateRemoteShareOperation + share.setFolder(path.endsWith(FileUtils.PATH_SEPARATOR)); + + share.setPasswordProtected(!TextUtils.isEmpty(password)); + getStorageManager().saveShare(share); + } + + public String getPassword() { + return this.password; + } + + public String getPath() { + return this.path; + } + + public void setPermissions(int permissions) { + this.permissions = permissions; + } + + public void setExpirationDateInMillis(long expirationDateInMillis) { + this.expirationDateInMillis = expirationDateInMillis; + } + + public void setPassword(String password) { + this.password = password; + } +} + diff --git a/app/src/main/java/com/owncloud/android/operations/UpdateShareViaLinkOperation.java b/app/src/main/java/com/owncloud/android/operations/UpdateShareViaLinkOperation.java new file mode 100644 index 000000000000..834c08ac193d --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/UpdateShareViaLinkOperation.java @@ -0,0 +1,81 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.shares.GetShareRemoteOperation; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.UpdateShareRemoteOperation; +import com.owncloud.android.operations.common.SyncOperation; + +/** + * Updates an existing public share for a given file + */ +public class UpdateShareViaLinkOperation extends SyncOperation { + private String password; + private Boolean hideFileDownload; + private long expirationDateInMillis; + private final long shareId; + private String label; + + public UpdateShareViaLinkOperation(long shareId, FileDataStorageManager storageManager) { + super(storageManager); + + expirationDateInMillis = 0; + this.shareId = shareId; + } + + @Override + protected RemoteOperationResult run(OwnCloudClient client) { + OCShare publicShare = getStorageManager().getShareById(shareId); + + UpdateShareRemoteOperation updateOp = new UpdateShareRemoteOperation(publicShare.getRemoteId()); + updateOp.setPassword(password); + updateOp.setExpirationDate(expirationDateInMillis); + updateOp.setHideFileDownload(hideFileDownload); + updateOp.setLabel(label); + + RemoteOperationResult result = updateOp.execute(client); + + if (result.isSuccess()) { + // Retrieve updated share / save directly with password? -> no; the password is not to be saved + RemoteOperation getShareOp = new GetShareRemoteOperation(publicShare.getRemoteId()); + result = getShareOp.execute(client); + if (result.isSuccess()) { + OCShare share = (OCShare) result.getData().get(0); + getStorageManager().saveShare(share); + } + } + + return result; + } + + public String getPassword() { + return this.password; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setHideFileDownload(Boolean hideFileDownload) { + this.hideFileDownload = hideFileDownload; + } + + public void setExpirationDateInMillis(long expirationDateInMillis) { + this.expirationDateInMillis = expirationDateInMillis; + } + + public void setLabel(String label) { + this.label = label; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/UploadException.java b/app/src/main/java/com/owncloud/android/operations/UploadException.java new file mode 100644 index 000000000000..080fb505db9f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/UploadException.java @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.operations; + +public class UploadException extends Exception { + private static final long serialVersionUID = 5931153844211429915L; + + public UploadException() { + super(); + } + + public UploadException(String message) { + super(message); + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java new file mode 100644 index 000000000000..3e5cfcfa101d --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -0,0 +1,1783 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; +import android.text.format.Formatter; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.device.BatteryStatus; +import com.nextcloud.client.device.PowerManagementService; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.nextcloud.client.network.Connectivity; +import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.utils.autoRename.AutoRename; +import com.nextcloud.utils.e2ee.E2EVersionHelper; +import com.nextcloud.utils.extensions.RemoteOperationResultExtensionsKt; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.datamodel.e2e.v1.decrypted.Data; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata; +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFile; +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1; +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.db.OCUpload; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener; +import com.owncloud.android.lib.common.network.ProgressiveDataTransfer; +import com.owncloud.android.lib.common.operations.OperationCancelledException; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.ChunkedFileUploadRemoteOperation; +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation; +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation; +import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.operations.common.SyncOperation; +import com.owncloud.android.operations.e2e.E2EClientData; +import com.owncloud.android.operations.e2e.E2EData; +import com.owncloud.android.operations.e2e.E2EFiles; +import com.owncloud.android.operations.upload.UploadFileException; +import com.owncloud.android.operations.upload.UploadFileOperationExtensionsKt; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.EncryptionUtilsV2; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.FileUtil; +import com.owncloud.android.utils.MimeType; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.UriUtils; +import com.owncloud.android.utils.theme.CapabilityUtils; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.httpclient.methods.RequestEntity; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.spec.InvalidParameterSpecException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; + +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; +import kotlin.Triple; +import kotlin.Unit; + +/** + * Operation performing the update in the ownCloud server of a file that was modified locally. + */ +public class UploadFileOperation extends SyncOperation { + + private static final String TAG = UploadFileOperation.class.getSimpleName(); + + public static final int CREATED_BY_USER = 0; + public static final int CREATED_AS_INSTANT_PICTURE = 1; + public static final int CREATED_AS_INSTANT_VIDEO = 2; + public static final int MISSING_FILE_PERMISSION_NOTIFICATION_ID = 2501; + + /** + * OCFile which is to be uploaded. + */ + private OCFile mFile; + + /** + * Original OCFile which is to be uploaded in case file had to be renamed (if nameCollisionPolicy==RENAME and remote + * file already exists). + */ + private OCFile mOldFile; + private String mRemotePath; + private String mFolderUnlockToken; + private boolean mRemoteFolderToBeCreated; + private NameCollisionPolicy mNameCollisionPolicy; + private int mLocalBehaviour; + private int mCreatedBy; + private boolean mOnWifiOnly; + private boolean mWhileChargingOnly; + private boolean mIgnoringPowerSaveMode; + private final boolean mDisableRetries; + + private boolean mWasRenamed; + private long mOCUploadId; + /** + * Local path to file which is to be uploaded (before any possible renaming or moving). + */ + private String mOriginalStoragePath; + private final Set mDataTransferListeners = new HashSet<>(); + private OnRenameListener mRenameUploadListener; + + private final AtomicBoolean mCancellationRequested = new AtomicBoolean(false); + private final AtomicBoolean mUploadStarted = new AtomicBoolean(false); + + private Context mContext; + + private UploadFileRemoteOperation mUploadOperation; + + private RequestEntity mEntity; + + private final User user; + private final OCUpload mUpload; + private final UploadsStorageManager uploadsStorageManager; + private final ConnectivityService connectivityService; + private final PowerManagementService powerManagementService; + + private boolean encryptedAncestor; + private OCFile duplicatedEncryptedFile; + private AtomicBoolean missingPermissionThrown = new AtomicBoolean(false); + + public static OCFile obtainNewOCFileToUpload(String remotePath, String localPath, String mimeType) { + OCFile newFile = new OCFile(remotePath); + newFile.setStoragePath(localPath); + newFile.setLastSyncDateForProperties(0); + newFile.setLastSyncDateForData(0); + + // size + if (!TextUtils.isEmpty(localPath)) { + File localFile = new File(localPath); + newFile.setFileLength(localFile.length()); + newFile.setLastSyncDateForData(localFile.lastModified()); + } // don't worry about not assigning size, the problems with localPath + // are checked when the UploadFileOperation instance is created + + // MIME type + if (TextUtils.isEmpty(mimeType)) { + newFile.setMimeType(MimeTypeUtil.getBestMimeTypeByFilename(localPath)); + } else { + newFile.setMimeType(mimeType); + } + + return newFile; + } + + public UploadFileOperation(UploadsStorageManager uploadsStorageManager, + ConnectivityService connectivityService, + PowerManagementService powerManagementService, + User user, + OCFile file, + OCUpload upload, + NameCollisionPolicy nameCollisionPolicy, + int localBehaviour, + Context context, + boolean onWifiOnly, + boolean whileChargingOnly, + FileDataStorageManager storageManager) { + this(uploadsStorageManager, + connectivityService, + powerManagementService, + user, + file, + upload, + nameCollisionPolicy, + localBehaviour, + context, + onWifiOnly, + whileChargingOnly, + true, + storageManager); + } + + public UploadFileOperation(UploadsStorageManager uploadsStorageManager, + ConnectivityService connectivityService, + PowerManagementService powerManagementService, + User user, + OCFile file, + OCUpload upload, + NameCollisionPolicy nameCollisionPolicy, + int localBehaviour, + Context context, + boolean onWifiOnly, + boolean whileChargingOnly, + boolean disableRetries, + FileDataStorageManager storageManager) { + super(storageManager); + + if (upload == null) { + Log_OC.e(TAG, "UploadFileOperation upload is null cant construct"); + throw new IllegalArgumentException("Illegal NULL file in UploadFileOperation creation"); + } + if (TextUtils.isEmpty(upload.getLocalPath())) { + Log_OC.e(TAG, "UploadFileOperation local path is null cant construct"); + throw new IllegalArgumentException( + "Illegal file in UploadFileOperation; storage path invalid: " + + upload.getLocalPath()); + } + Log_OC.d(TAG, "creating upload file operation, user: " + user.getAccountName() + " upload account name " + upload.getAccountName()); + this.uploadsStorageManager = uploadsStorageManager; + this.connectivityService = connectivityService; + this.powerManagementService = powerManagementService; + this.user = user; + mUpload = upload; + if (file == null) { + Log_OC.w(TAG, "UploadFileOperation file is null, obtaining from upload"); + mFile = obtainNewOCFileToUpload( + upload.getRemotePath(), + upload.getLocalPath(), + upload.getMimeType()); + } else { + mFile = file; + } + mOnWifiOnly = onWifiOnly; + mWhileChargingOnly = whileChargingOnly; + mRemotePath = upload.getRemotePath(); + mNameCollisionPolicy = nameCollisionPolicy; + mLocalBehaviour = localBehaviour; + mOriginalStoragePath = mFile.getStoragePath(); + mContext = context; + mOCUploadId = upload.getUploadId(); + mCreatedBy = upload.getCreatedBy(); + mRemoteFolderToBeCreated = upload.isCreateRemoteFolder(); + // Ignore power save mode only if user explicitly created this upload + mIgnoringPowerSaveMode = mCreatedBy == CREATED_BY_USER; + mFolderUnlockToken = upload.getFolderUnlockToken(); + mDisableRetries = disableRetries; + } + + public boolean isWifiRequired() { + return mOnWifiOnly; + } + + public boolean isChargingRequired() { + return mWhileChargingOnly; + } + + public boolean isIgnoringPowerSaveMode() { + return mIgnoringPowerSaveMode; + } + + public User getUser() { + return user; + } + + public String getFileName() { + return (mFile != null) ? mFile.getFileName() : null; + } + + public OCFile getFile() { + return mFile; + } + + /** + * If remote file was renamed, return original OCFile which was uploaded. Is null is file was not renamed. + */ + @Nullable + public OCFile getOldFile() { + return mOldFile; + } + + public String getOriginalStoragePath() { + return mOriginalStoragePath; + } + + public String getStoragePath() { + return mFile.getStoragePath(); + } + + public String getRemotePath() { + return mFile.getRemotePath(); + } + + public String getDecryptedRemotePath() { + return mFile.getDecryptedRemotePath(); + } + + public String getMimeType() { + return mFile.getMimeType(); + } + + public int getLocalBehaviour() { + return mLocalBehaviour; + } + + public UploadFileOperation setRemoteFolderToBeCreated() { + mRemoteFolderToBeCreated = true; + + return this; + } + + public boolean wasRenamed() { + return mWasRenamed; + } + + public void setCreatedBy(int createdBy) { + mCreatedBy = createdBy; + if (createdBy < CREATED_BY_USER || CREATED_AS_INSTANT_VIDEO < createdBy) { + mCreatedBy = CREATED_BY_USER; + } + } + + public int getCreatedBy() { + return mCreatedBy; + } + + public boolean isInstantPicture() { + return mCreatedBy == CREATED_AS_INSTANT_PICTURE; + } + + public boolean isInstantVideo() { + return mCreatedBy == CREATED_AS_INSTANT_VIDEO; + } + + public void setOCUploadId(long id) { + mOCUploadId = id; + } + + public long getOCUploadId() { + return mOCUploadId; + } + + public Set getDataTransferListeners() { + return mDataTransferListeners; + } + + public void addDataTransferProgressListener(OnDatatransferProgressListener listener) { + synchronized (mDataTransferListeners) { + mDataTransferListeners.add(listener); + } + if (mEntity != null) { + ((ProgressiveDataTransfer) mEntity).addDataTransferProgressListener(listener); + } + if (mUploadOperation != null) { + mUploadOperation.addDataTransferProgressListener(listener); + } + } + + public void removeDataTransferProgressListener(OnDatatransferProgressListener listener) { + synchronized (mDataTransferListeners) { + mDataTransferListeners.remove(listener); + } + if (mEntity != null) { + ((ProgressiveDataTransfer) mEntity).removeDataTransferProgressListener(listener); + } + if (mUploadOperation != null) { + mUploadOperation.removeDataTransferProgressListener(listener); + } + } + + public UploadFileOperation addRenameUploadListener(OnRenameListener listener) { + mRenameUploadListener = listener; + + return this; + } + + public Context getContext() { + return mContext; + } + + public boolean isMissingPermissionThrown() { + return missingPermissionThrown.get(); + } + + @Override + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + protected RemoteOperationResult run(OwnCloudClient client) { + Log_OC.d(TAG, "------- Upload File Operation Started -------"); + if (TextUtils.isEmpty(getStoragePath())) { + Log_OC.e(TAG, "Upload cancelled for " + getStoragePath() + ": file path is null or empty."); + return new RemoteOperationResult<>(new UploadFileException.EmptyOrNullFilePath()); + } + + final var localFile = new File(getStoragePath()); + if (!localFile.exists()) { + Log_OC.e(TAG, "Upload cancelled for " + getStoragePath() + ": local file not exists."); + return new RemoteOperationResult<>(ResultCode.LOCAL_FILE_NOT_FOUND); + } + + if (!localFile.canRead()) { + Log_OC.e(TAG, "Upload cancelled for " + getStoragePath() + ": file is not readable or inaccessible."); + UploadFileOperationExtensionsKt.showStoragePermissionNotification(this); + missingPermissionThrown.set(true); + return new RemoteOperationResult<>(new UploadFileException.MissingPermission()); + } + + mCancellationRequested.set(false); + mUploadStarted.set(true); + + updateSize(0); + Log_OC.d(TAG, "file size set to 0KB before upload"); + + String remoteParentPath = new File(getRemotePath()).getParent(); + if (remoteParentPath == null) { + Log_OC.e(TAG, "remoteParentPath is null: " + getRemotePath()); + return new RemoteOperationResult<>(ResultCode.UNKNOWN_ERROR); + } + remoteParentPath = remoteParentPath.endsWith(OCFile.PATH_SEPARATOR) + ? remoteParentPath + : remoteParentPath + OCFile.PATH_SEPARATOR; + + final String renamedRemoteParentPath = AutoRename.INSTANCE.rename(remoteParentPath, getCapabilities()); + if (!remoteParentPath.equals(renamedRemoteParentPath)) { + Log_OC.w(TAG, "remoteParentPath was renamed: " + remoteParentPath + " → " + renamedRemoteParentPath); + } + remoteParentPath = renamedRemoteParentPath; + + OCFile parent = getStorageManager().getFileByPath(remoteParentPath); + Log_OC.d(TAG, "parent lookup for path: " + remoteParentPath + " → " + + (parent == null ? "not found in DB" : "found, id=" + parent.getFileId())); + + // in case of a fresh upload with subfolder, where parent does not exist yet + if (parent == null && (mFolderUnlockToken == null || mFolderUnlockToken.isEmpty())) { + Log_OC.d(TAG, "parent not in DB and no unlock token, attempting to grant folder existence: " + + remoteParentPath); + final var result = grantFolderExistence(remoteParentPath, client); + + if (!result.isSuccess()) { + Log_OC.e(TAG, "grantFolderExistence failed for: " + remoteParentPath + ", code: " + + result.getCode() + ", message: " + result.getMessage()); + return result; + } + + parent = getStorageManager().getFileByPath(remoteParentPath); + if (parent == null) { + Log_OC.e(TAG, "parent still null after grantFolderExistence: " + remoteParentPath); + return new RemoteOperationResult<>(ResultCode.UNKNOWN_ERROR); + } + + Log_OC.d(TAG, "parent created and retrieved successfully: " + remoteParentPath + ", id=" + + parent.getFileId()); + } + + if (parent == null) { + Log_OC.e(TAG, "parent is null, cannot proceed: " + remoteParentPath + "," + " unlock token: " + mFolderUnlockToken); + return new RemoteOperationResult<>(false, "Parent folder not found", HttpStatus.SC_NOT_FOUND); + } + + // - resume of encrypted upload, then parent file exists already as unlock is only for direct parent + mFile.setParentId(parent.getFileId()); + + // check if any parent is encrypted + encryptedAncestor = FileStorageUtils.checkEncryptionStatus(parent, getStorageManager()); + mFile.setEncrypted(encryptedAncestor); + + if (encryptedAncestor) { + Log_OC.d(TAG, "⬆️🔗" + "encrypted upload"); + return encryptedUpload(client, parent); + } else { + Log_OC.d(TAG, "⬆️" + "normal upload"); + return normalUpload(client); + } + } + + // region E2E Upload + @SuppressLint("AndroidLintUseSparseArrays") // gson cannot handle sparse arrays easily, therefore use hashmap + private RemoteOperationResult encryptedUpload(OwnCloudClient client, OCFile parentFile) { + RemoteOperationResult result = null; + E2EFiles e2eFiles = new E2EFiles(parentFile, null, new File(mOriginalStoragePath), null, null); + FileLock fileLock = null; + long size; + boolean metadataExists = false; + String token = null; + Object object = null; + FileChannel channel = null; + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(getContext()); + String publicKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); + + try { + result = checkConditions(e2eFiles.getOriginalFile()); + + if (result != null) { + return result; + } + + long counter = getE2ECounter(parentFile); + + try { + token = getFolderUnlockTokenOrLockFolder(client, parentFile, counter); + } catch (Exception e) { + Log_OC.e(TAG, "Failed to lock folder", e); + return new RemoteOperationResult<>(e); + } + + // Update metadata + EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); + object = EncryptionUtils.downloadFolderMetadata(parentFile, client, mContext, user); + if (object instanceof DecryptedFolderMetadataFileV1 decrypted && decrypted.getMetadata() != null) { + metadataExists = true; + } + + if (isEndToEndVersionAtLeastV2()) { + if (object == null) { + return new RemoteOperationResult<>(new IllegalStateException("Metadata does not exist")); + } + } else { + object = getDecryptedFolderMetadataV1(publicKey, object); + } + + E2EClientData clientData = new E2EClientData(client, token, publicKey); + + List fileNames = getCollidedFileNames(object); + + final var collisionResult = checkNameCollision(parentFile, client, fileNames, parentFile.isEncrypted()); + if (collisionResult != null) { + result = collisionResult; + return collisionResult; + } + + mFile.setDecryptedRemotePath(parentFile.getDecryptedRemotePath() + e2eFiles.getOriginalFile().getName()); + String expectedPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mFile); + e2eFiles.setExpectedFile(new File(expectedPath)); + + result = copyFile(e2eFiles.getOriginalFile(), expectedPath); + if (!result.isSuccess()) { + return result; + } + + long lastModifiedTimestamp = e2eFiles.getOriginalFile().lastModified() / 1000; + Long creationTimestamp = FileUtil.getCreationTimestamp(e2eFiles.getOriginalFile()); + if (creationTimestamp == null) { + Log_OC.e(TAG, "UploadFileOperation creationTimestamp cannot be null"); + throw new NullPointerException("creationTimestamp cannot be null"); + } + + E2EData e2eData = getE2EData(object); + e2eFiles.setEncryptedTempFile(e2eData.getEncryptedFile().getEncryptedFile()); + if (e2eFiles.getEncryptedTempFile() == null) { + Log_OC.e(TAG, "UploadFileOperation encryptedTempFile cannot be null"); + throw new NullPointerException("encryptedTempFile cannot be null"); + } + + Triple channelResult = initFileChannel(result, fileLock, e2eFiles); + fileLock = channelResult.getFirst(); + result = channelResult.getSecond(); + channel = channelResult.getThird(); + + size = getChannelSize(channel); + updateSize(size); + setUploadOperationForE2E(token, e2eFiles.getEncryptedTempFile(), e2eData.getEncryptedFileName(), lastModifiedTimestamp, creationTimestamp, size); + + result = performE2EUpload(clientData); + + if (result.isSuccess()) { + updateMetadataForE2E(object, e2eData, clientData, e2eFiles, arbitraryDataProvider, encryptionUtilsV2, metadataExists); + } + } catch (FileNotFoundException e) { + Log_OC.e(TAG, mFile.getStoragePath() + " does not exist anymore"); + result = new RemoteOperationResult<>(ResultCode.LOCAL_FILE_NOT_FOUND); + } catch (OverlappingFileLockException e) { + Log_OC.e(TAG, "Overlapping file lock exception"); + result = new RemoteOperationResult<>(ResultCode.LOCK_FAILED); + } catch (Exception e) { + Log_OC.e(TAG, "UploadFileOperation exception: " + e.getLocalizedMessage()); + result = new RemoteOperationResult<>(e); + } finally { + result = cleanupE2EUpload(fileLock, channel, e2eFiles, result, object, client, token); + + // update upload status + uploadsStorageManager.updateDatabaseUploadResult(result, this); + } + + completeE2EUpload(result, e2eFiles, client); + + return result; + } + + private boolean isEndToEndVersionAtLeastV2() { + final var capability = CapabilityUtils.getCapability(mContext); + return E2EVersionHelper.INSTANCE.isV2Plus(capability); + } + + private long getE2ECounter(OCFile parentFile) { + long counter = -1; + + if (isEndToEndVersionAtLeastV2()) { + counter = parentFile.getE2eCounter() + 1; + } + + return counter; + } + + private String getFolderUnlockTokenOrLockFolder(OwnCloudClient client, OCFile parentFile, long counter) throws UploadException { + if (mFolderUnlockToken != null && !mFolderUnlockToken.isEmpty()) { + Log_OC.d(TAG, "Reusing existing folder unlock token from previous upload attempt"); + return mFolderUnlockToken; + } + + String token = EncryptionUtils.lockFolder(parentFile, client, counter); + if (token == null || token.isEmpty()) { + Log_OC.e(TAG, "Lock folder returned null or empty token"); + throw new UploadException("Failed to lock folder: token is null or empty"); + } + + mUpload.setFolderUnlockToken(token); + uploadsStorageManager.updateUpload(mUpload); + + Log_OC.d(TAG, "Folder locked successfully, token saved"); + return token; + } + + private DecryptedFolderMetadataFileV1 getDecryptedFolderMetadataV1(String publicKey, Object object) + throws NoSuchPaddingException, IllegalBlockSizeException, CertificateException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { + + DecryptedFolderMetadataFileV1 metadata = new DecryptedFolderMetadataFileV1(); + metadata.setMetadata(new DecryptedMetadata()); + metadata.getMetadata().setVersion(1.2); + metadata.getMetadata().setMetadataKeys(new HashMap<>()); + String metadataKey = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.generateKey()); + String encryptedMetadataKey = EncryptionUtils.encryptStringAsymmetric(metadataKey, publicKey); + metadata.getMetadata().setMetadataKey(encryptedMetadataKey); + + if (object instanceof DecryptedFolderMetadataFileV1) { + metadata = (DecryptedFolderMetadataFileV1) object; + } + + return metadata; + } + + private List getCollidedFileNames(Object object) { + List result = new ArrayList<>(); + + if (object instanceof DecryptedFolderMetadataFileV1 metadata) { + for (DecryptedFile file : metadata.getFiles().values()) { + result.add(file.getEncrypted().getFilename()); + } + } else if (object instanceof DecryptedFolderMetadataFile metadataFile) { + Map files = metadataFile.getMetadata().getFiles(); + for (com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile file : files.values()) { + result.add(file.getFilename()); + } + } + + return result; + } + + private String getEncryptedFileName(Object object) { + String encryptedFileName = EncryptionUtils.generateUid(); + + if (object instanceof DecryptedFolderMetadataFileV1 metadata) { + while (metadata.getFiles().get(encryptedFileName) != null) { + encryptedFileName = EncryptionUtils.generateUid(); + } + } else { + while (((DecryptedFolderMetadataFile) object).getMetadata().getFiles().get(encryptedFileName) != null) { + encryptedFileName = EncryptionUtils.generateUid(); + } + } + + return encryptedFileName; + } + + private void setUploadOperationForE2E(String token, + File encryptedTempFile, + String encryptedFileName, + long lastModifiedTimestamp, + long creationTimestamp, + long size) { + + if (size > ChunkedFileUploadRemoteOperation.CHUNK_SIZE_MOBILE) { + boolean onWifiConnection = connectivityService.getConnectivity().isWifi(); + + mUploadOperation = new ChunkedFileUploadRemoteOperation(encryptedTempFile.getAbsolutePath(), + mFile.getParentRemotePath() + encryptedFileName, + mFile.getMimeType(), + mFile.getEtagInConflict(), + lastModifiedTimestamp, + onWifiConnection, + token, + creationTimestamp, + mDisableRetries + ); + } else { + mUploadOperation = new UploadFileRemoteOperation(encryptedTempFile.getAbsolutePath(), + mFile.getParentRemotePath() + encryptedFileName, + mFile.getMimeType(), + mFile.getEtagInConflict(), + lastModifiedTimestamp, + creationTimestamp, + token, + mDisableRetries + ); + } + } + + private Triple initFileChannel(RemoteOperationResult result, FileLock fileLock, E2EFiles e2eFiles) throws IOException { + FileChannel channel = null; + + try { + RandomAccessFile randomAccessFile = new RandomAccessFile(mFile.getStoragePath(), "rw"); + channel = randomAccessFile.getChannel(); + fileLock = channel.tryLock(); + } catch (IOException ioException) { + Log_OC.d(TAG, "Error caught at getChannelFromFile: " + ioException); + + // this basically means that the file is on SD card + // try to copy file to temporary dir if it doesn't exist + String temporalPath = FileStorageUtils.getInternalTemporalPath(user.getAccountName(), mContext) + + mFile.getRemotePath(); + mFile.setStoragePath(temporalPath); + e2eFiles.setTemporalFile(new File(temporalPath)); + + if (e2eFiles.getTemporalFile() == null) { + throw new NullPointerException("Original file cannot be null"); + } + + Files.deleteIfExists(Paths.get(temporalPath)); + result = copy(e2eFiles.getOriginalFile(), e2eFiles.getTemporalFile()); + + if (result.isSuccess()) { + if (e2eFiles.getTemporalFile().length() == e2eFiles.getOriginalFile().length()) { + try (RandomAccessFile randomAccessFile = new RandomAccessFile(e2eFiles.getTemporalFile().getAbsolutePath(), "rw")) { + channel = randomAccessFile.getChannel(); + fileLock = channel.tryLock(); + } catch (IOException e) { + Log_OC.d(TAG, "Error caught at getChannelFromFile: " + e); + } + } else { + result = new RemoteOperationResult<>(ResultCode.LOCK_FAILED); + } + } + } + + return new Triple<>(fileLock, result, channel); + } + + private long getChannelSize(FileChannel channel) { + try { + return channel.size(); + } catch (IOException e1) { + return new File(mFile.getStoragePath()).length(); + } + } + + private RemoteOperationResult performE2EUpload(E2EClientData data) throws OperationCancelledException { + for (OnDatatransferProgressListener mDataTransferListener : mDataTransferListeners) { + mUploadOperation.addDataTransferProgressListener(mDataTransferListener); + } + + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + + var result = mUploadOperation.execute(data.getClient()); + + /// move local temporal file or original file to its corresponding + // location in the Nextcloud local folder + if (!result.isSuccess() && result.getHttpCode() == HttpStatus.SC_PRECONDITION_FAILED) { + result = new RemoteOperationResult<>(ResultCode.SYNC_CONFLICT); + } + + return result; + } + + private E2EData getE2EData(Object object) throws InvalidAlgorithmParameterException, NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, InvalidParameterSpecException, IOException { + byte[] key = EncryptionUtils.generateKey(); + byte[] iv = EncryptionUtils.randomBytes(EncryptionUtils.ivLength); + Cipher cipher = EncryptionUtils.getCipher(Cipher.ENCRYPT_MODE, key, iv); + File file = new File(mFile.getStoragePath()); + EncryptedFile encryptedFile = EncryptionUtils.encryptFile(user.getAccountName(), file, cipher); + String encryptedFileName = getEncryptedFileName(object); + + if (key == null) { + throw new NullPointerException("key cannot be null"); + } + + return new E2EData(key, iv, encryptedFile, encryptedFileName); + } + + private void updateMetadataForE2E(Object object, E2EData e2eData, E2EClientData clientData, E2EFiles e2eFiles, ArbitraryDataProvider arbitraryDataProvider, EncryptionUtilsV2 encryptionUtilsV2, boolean metadataExists) + + throws InvalidAlgorithmParameterException, UploadException, NoSuchPaddingException, IllegalBlockSizeException, CertificateException, + NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { + + final var filename = new File(mFile.getRemotePath()).getName(); + mFile.setDecryptedRemotePath(e2eFiles.getParentFile().getDecryptedRemotePath() + filename); + mFile.setRemotePath(e2eFiles.getParentFile().getRemotePath() + e2eData.getEncryptedFileName()); + + + if (object instanceof DecryptedFolderMetadataFileV1 metadata) { + updateMetadataForV1(metadata, + e2eData, + clientData, + e2eFiles.getParentFile(), + arbitraryDataProvider, + metadataExists); + } else if (object instanceof DecryptedFolderMetadataFile metadata) { + updateMetadataForV2(metadata, + encryptionUtilsV2, + e2eData, + clientData, + e2eFiles.getParentFile()); + } + } + + private void updateMetadataForV1(DecryptedFolderMetadataFileV1 metadata, E2EData e2eData, E2EClientData clientData, + OCFile parentFile, ArbitraryDataProvider arbitraryDataProvider, boolean metadataExists) + + throws InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, + CertificateException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException, UploadException { + + DecryptedFile decryptedFile = new DecryptedFile(); + Data data = new Data(); + data.setFilename(mFile.getDecryptedFileName()); + data.setMimetype(mFile.getMimeType()); + data.setKey(EncryptionUtils.encodeBytesToBase64String(e2eData.getKey())); + decryptedFile.setEncrypted(data); + decryptedFile.setInitializationVector(EncryptionUtils.encodeBytesToBase64String(e2eData.getIv())); + decryptedFile.setAuthenticationTag(e2eData.getEncryptedFile().getAuthenticationTag()); + + metadata.getFiles().put(e2eData.getEncryptedFileName(), decryptedFile); + + EncryptedFolderMetadataFileV1 encryptedFolderMetadata = + EncryptionUtils.encryptFolderMetadata(metadata, + clientData.getPublicKey(), + parentFile.getLocalId(), + user, + arbitraryDataProvider + ); + + String serializedFolderMetadata; + + if (metadata.getMetadata().getMetadataKey() != null) { + serializedFolderMetadata = EncryptionUtils.serializeJSON(encryptedFolderMetadata, true); + } else { + serializedFolderMetadata = EncryptionUtils.serializeJSON(encryptedFolderMetadata); + } + + // upload metadata + EncryptionUtils.uploadMetadata(parentFile, + serializedFolderMetadata, + clientData.getToken(), + clientData.getClient(), + metadataExists, + E2EVersionHelper.INSTANCE.latestVersion(false), + "", + arbitraryDataProvider, + user); + } + + + private void updateMetadataForV2(DecryptedFolderMetadataFile metadata, EncryptionUtilsV2 encryptionUtilsV2, E2EData e2eData, E2EClientData clientData, OCFile parentFile) throws UploadException { + encryptionUtilsV2.addFileToMetadata( + e2eData.getEncryptedFileName(), + mFile, + e2eData.getIv(), + e2eData.getEncryptedFile().getAuthenticationTag(), + e2eData.getKey(), + metadata, + getStorageManager()); + + // upload metadata + encryptionUtilsV2.serializeAndUploadMetadata(parentFile, + metadata, + clientData.getToken(), + clientData.getClient(), + true, + mContext, + user, + getStorageManager()); + } + + private void completeE2EUpload(RemoteOperationResult result, E2EFiles e2eFiles, OwnCloudClient client) { + if (result.isSuccess()) { + handleLocalBehaviour(e2eFiles.getTemporalFile(), e2eFiles.getExpectedFile(), e2eFiles.getOriginalFile(), client); + } else if (RemoteOperationResultExtensionsKt.isConflict(result.getCode())) { + getStorageManager().saveConflict(mFile, mFile.getEtagInConflict()); + } + + e2eFiles.deleteTemporalFile(); + } + + private RemoteOperationResult cleanupE2EUpload(FileLock fileLock, FileChannel channel, E2EFiles e2eFiles, RemoteOperationResult result, Object object, OwnCloudClient client, String token) { + mUploadStarted.set(false); + + if (fileLock != null) { + try { + // Only release if the channel is still open/valid + if (channel != null && channel.isOpen()) { + fileLock.release(); + } + } catch (IOException e) { + Log_OC.e(TAG, "Failed to unlock file with path " + mFile.getStoragePath()); + } + } + + if (channel != null) { + try { + channel.close(); + } catch (IOException e) { + Log_OC.e(TAG, "Failed to close file channel", e); + } + } + + e2eFiles.deleteTemporalFileWithOriginalFileComparison(); + + if (result == null) { + result = new RemoteOperationResult<>(ResultCode.UNKNOWN_ERROR); + } + + logResult(result, mFile.getStoragePath(), mFile.getRemotePath()); + + if (token == null || token.isEmpty()) { + Log_OC.e(TAG, "CRITICAL ERROR: Folder was locked but token is null/empty. Cannot unlock! " + + "Folder: " + e2eFiles.getParentFile().getFileName()); + RemoteOperationResult tokenError = new RemoteOperationResult<>( + new IllegalStateException("Folder locked but token lost - manual intervention may be required") + ); + + // Override result only if original operation succeeded + if (result.isSuccess()) { + result = tokenError; + } + return result; + } + + // Unlock must be done otherwise folder stays locked and user can't upload any file + RemoteOperationResult unlockFolderResult; + try { + if (object instanceof DecryptedFolderMetadataFileV1) { + unlockFolderResult = EncryptionUtils.unlockFolderV1(e2eFiles.getParentFile(), client, token); + } else { + unlockFolderResult = EncryptionUtils.unlockFolder(e2eFiles.getParentFile(), client, token); + } + } catch (Exception e) { + Log_OC.e(TAG, "CRITICAL ERROR: Exception during folder unlock", e); + unlockFolderResult = new RemoteOperationResult<>(e); + } + + if (unlockFolderResult != null && !unlockFolderResult.isSuccess()) { + result = unlockFolderResult; + } + + if (unlockFolderResult != null && unlockFolderResult.isSuccess()) { + Log_OC.d(TAG, "Folder successfully unlocked: " + e2eFiles.getParentFile().getFileName()); + + if (duplicatedEncryptedFile != null) { + FileUploadHelper.Companion.instance().removeDuplicatedFile(duplicatedEncryptedFile, client, user, () -> { + duplicatedEncryptedFile = null; + return Unit.INSTANCE; + }); + } + } + + e2eFiles.deleteEncryptedTempFile(); + + return result; + } + // endregion + + private RemoteOperationResult checkConditions(File originalFile) { + RemoteOperationResult remoteOperationResult = null; + + // check that connectivity conditions are met and delays the upload otherwise + Connectivity connectivity = connectivityService.getConnectivity(); + if (mOnWifiOnly && (!connectivity.isWifi() || connectivity.isMetered())) { + Log_OC.d(TAG, "Upload delayed until WiFi is available: " + getRemotePath()); + remoteOperationResult = new RemoteOperationResult(ResultCode.DELAYED_FOR_WIFI); + } + + // check if charging conditions are met and delays the upload otherwise + final BatteryStatus battery = powerManagementService.getBattery(); + if (mWhileChargingOnly && !battery.isCharging()) { + Log_OC.d(TAG, "Upload delayed until the device is charging: " + getRemotePath()); + remoteOperationResult = new RemoteOperationResult(ResultCode.DELAYED_FOR_CHARGING); + } + + // check that device is not in power save mode + if (!mIgnoringPowerSaveMode && powerManagementService.isPowerSavingEnabled()) { + Log_OC.d(TAG, "Upload delayed because device is in power save mode: " + getRemotePath()); + remoteOperationResult = new RemoteOperationResult(ResultCode.DELAYED_IN_POWER_SAVE_MODE); + } + + // check if the file continues existing before schedule the operation + if (!originalFile.exists()) { + Log_OC.d(TAG, mOriginalStoragePath + " does not exist anymore"); + remoteOperationResult = new RemoteOperationResult(ResultCode.LOCAL_FILE_NOT_FOUND); + } + + // check that internet is not behind walled garden + if (!connectivityService.getConnectivity().isConnected() || connectivityService.isInternetWalled()) { + remoteOperationResult = new RemoteOperationResult(ResultCode.NO_NETWORK_CONNECTION); + } + + return remoteOperationResult; + } + + private RemoteOperationResult normalUpload(OwnCloudClient client) { + RemoteOperationResult result = null; + File temporalFile = null; + File originalFile = new File(mOriginalStoragePath); + File expectedFile = null; + + try { + Log_OC.d(TAG, "checking conditions"); + result = checkConditions(originalFile); + if (result != null) { + return result; + } + + final var collisionResult = checkNameCollision(null, client, null, false); + if (collisionResult != null) { + Log_OC.e(TAG, "name collision detected"); + result = collisionResult; + return collisionResult; + } + + String expectedPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mFile); + expectedFile = new File(expectedPath); + + result = copyFile(originalFile, expectedPath); + if (!result.isSuccess()) { + Log_OC.e(TAG, "file copying failed"); + return result; + } + + // Get the last modification date of the file from the file system + long lastModifiedTimestamp = originalFile.lastModified() / 1000; + final Long creationTimestamp = FileUtil.getCreationTimestamp(originalFile); + + Path filePath = Paths.get(mFile.getStoragePath()); + + // file does not exists in storage + if (!Files.exists(filePath)) { + Log_OC.e(TAG, "file not found exception: normal upload, probably file in sd card"); + String temporalPath = FileStorageUtils.getInternalTemporalPath(user.getAccountName(), mContext) + + mFile.getRemotePath(); + mFile.setStoragePath(temporalPath); + temporalFile = new File(temporalPath); + + Files.deleteIfExists(Paths.get(temporalPath)); + result = copy(originalFile, temporalFile); + + if (!result.isSuccess()) return result; + + if (temporalFile.length() != originalFile.length()) { + Log_OC.e(TAG, "temporal file and original file lengths are not same - result is LOCK_FAILED"); + result = new RemoteOperationResult<>(ResultCode.LOCK_FAILED); + } + filePath = temporalFile.toPath(); + } + + // file exists in storage + try (FileChannel channel = FileChannel.open(filePath, StandardOpenOption.READ)) { + FileLock fileLock = null; + try { + // request a shared lock instead of exclusive one, since we are just reading file + fileLock = channel.tryLock(0L, Long.MAX_VALUE, true); + Log_OC.d(TAG ,"🔒" + "file locked"); + } catch (OverlappingFileLockException e) { + Log_OC.e(TAG, "shared lock overlap detected; proceeding safely."); + } + + // determine size + long size; + try { + size = channel.size(); + } catch (Exception e) { + Log_OC.e(TAG, "failed to determine file size from channel: ", e); + + try { + size = Files.size(filePath); + } catch (Exception exception) { + Log_OC.e(TAG, "failed to determine file size from nio.File: ", exception); + result = new RemoteOperationResult<>(ResultCode.FILE_NOT_FOUND); + return result; + } + } + + final var formattedFileSize = Formatter.formatFileSize(mContext, size); + updateSize(size); + Log_OC.d(TAG, "file size set to " + formattedFileSize); + + // decide whether chunked or not + if (size > ChunkedFileUploadRemoteOperation.CHUNK_SIZE_MOBILE) { + Log_OC.d(TAG, "chunked upload operation will be used"); + + boolean onWifiConnection = connectivityService.getConnectivity().isWifi(); + mUploadOperation = new ChunkedFileUploadRemoteOperation( + mFile.getStoragePath(), mFile.getRemotePath(), mFile.getMimeType(), + mFile.getEtagInConflict(), lastModifiedTimestamp, creationTimestamp, + onWifiConnection, mDisableRetries); + } else { + Log_OC.d(TAG, "upload file operation will be used"); + + mUploadOperation = new UploadFileRemoteOperation( + mFile.getStoragePath(), mFile.getRemotePath(), mFile.getMimeType(), + mFile.getEtagInConflict(), lastModifiedTimestamp, creationTimestamp, + mDisableRetries); + } + + Log_OC.d(TAG, "upload type operation determined"); + + /** + * Adds the onTransferProgress in FileUploadWorker + * {@link FileUploadWorker#onTransferProgress(long, long, long, String)()} + */ + for (OnDatatransferProgressListener mDataTransferListener : mDataTransferListeners) { + mUploadOperation.addDataTransferProgressListener(mDataTransferListener); + } + + if (mCancellationRequested.get()) { + Log_OC.e(TAG, "upload operation cancelled"); + throw new OperationCancelledException(); + } + + // execute + if (result.isSuccess() && mUploadOperation != null) { + Log_OC.d(TAG, "upload operation completed"); + result = mUploadOperation.execute(client); + } + + // move local temporal file or original file to its corresponding + // location in the Nextcloud local folder + if (!result.isSuccess() && result.getHttpCode() == HttpStatus.SC_PRECONDITION_FAILED) { + Log_OC.e(TAG, "upload operation failed with SC_PRECONDITION_FAILED"); + result = new RemoteOperationResult<>(ResultCode.SYNC_CONFLICT); + } + + if (fileLock != null && fileLock.isValid()) { + fileLock.release(); + Log_OC.d(TAG ,"🔓" + "file lock released"); + } + } + } catch (FileNotFoundException e) { + Log_OC.e(TAG, "normalupload(): file not found exception"); + result = new RemoteOperationResult<>(ResultCode.LOCAL_FILE_NOT_FOUND); + } catch (Exception e) { + Log_OC.e(TAG, "normalupload(): exception: ", e); + result = new RemoteOperationResult<>(e); + } finally { + Log_OC.d(TAG, "normalupload(): finally block"); + + mUploadStarted.set(false); + + // clean up temporal file if it exists + try { + if (temporalFile != null) { + if (temporalFile.exists() && !temporalFile.delete()) { + Log_OC.e(TAG, "Could not delete temporal file"); + } + } else { + Log_OC.d(TAG, "temporal file is null - internal storage is used instead of sd-card"); + } + } catch (Exception e) { + Log_OC.e(TAG, "an exception occurred during deletion of temporal file: ", e); + } + + if (result == null) { + Log_OC.e(TAG, "result is null, UNKNOWN_ERROR"); + result = new RemoteOperationResult<>(ResultCode.UNKNOWN_ERROR); + } + + logResult(result, mOriginalStoragePath, mRemotePath); + uploadsStorageManager.updateDatabaseUploadResult(result, this); + } + + if (result.isSuccess()) { + handleLocalBehaviour(temporalFile, expectedFile, originalFile, client); + } else if (result.getCode() == ResultCode.SYNC_CONFLICT) { + getStorageManager().saveConflict(mFile, mFile.getEtagInConflict()); + } + + Log_OC.d(TAG, "returning normalupload() result"); + + return result; + } + + private void updateSize(long size) { + OCUpload ocUpload = uploadsStorageManager.getUploadById(getOCUploadId()); + if (ocUpload != null) { + ocUpload.setFileSize(size); + uploadsStorageManager.updateUpload(ocUpload); + } + } + + private void logResult(RemoteOperationResult result, String sourcePath, String targetPath) { + if (result.isSuccess()) { + Log_OC.i(TAG, "Upload of " + sourcePath + " to " + targetPath + ": " + result.getLogMessage()); + } else { + if (result.getException() != null) { + if (result.isCancelled()) { + Log_OC.w(TAG, "Upload of " + sourcePath + " to " + targetPath + ": " + + result.getLogMessage()); + } else { + Log_OC.e(TAG, "Upload of " + sourcePath + " to " + targetPath + ": " + + result.getLogMessage(), result.getException()); + } + } else { + Log_OC.e(TAG, "Upload of " + sourcePath + " to " + targetPath + ": " + result.getLogMessage()); + } + } + } + + private RemoteOperationResult copyFile(File originalFile, String expectedPath) throws OperationCancelledException, + IOException { + if (mLocalBehaviour == FileUploadWorker.LOCAL_BEHAVIOUR_COPY && !mOriginalStoragePath.equals(expectedPath)) { + String temporalPath = FileStorageUtils.getInternalTemporalPath(user.getAccountName(), mContext) + + mFile.getRemotePath(); + mFile.setStoragePath(temporalPath); + File temporalFile = new File(temporalPath); + + return copy(originalFile, temporalFile); + } + + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + + return new RemoteOperationResult<>(ResultCode.OK); + } + + @CheckResult + private RemoteOperationResult checkNameCollision(OCFile parentFile, + OwnCloudClient client, + List fileNames, + boolean encrypted) + throws OperationCancelledException { + Log_OC.d(TAG, "Checking name collision in server"); + + boolean isFileExists = existsFile(client, mRemotePath, fileNames, encrypted); + + if (isFileExists) { + switch (mNameCollisionPolicy) { + case SKIP: + Log_OC.d(TAG, "user choose to skip upload if same file exists"); + return new RemoteOperationResult<>(ResultCode.OK); + case RENAME: + mRemotePath = getNewAvailableRemotePath(client, mRemotePath, fileNames, encrypted); + mWasRenamed = true; + createNewOCFile(mRemotePath); + Log_OC.d(TAG, "File renamed as " + mRemotePath); + if (mRenameUploadListener != null) { + mRenameUploadListener.onRenameUpload(); + } + break; + case OVERWRITE: + if (parentFile != null && encrypted) { + duplicatedEncryptedFile = getStorageManager().findDuplicatedFile(parentFile, mFile); + } + + Log_OC.d(TAG, "Overwriting file"); + break; + case ASK_USER: + Log_OC.d(TAG, "Name collision; asking the user what to do"); + + // check if its real SYNC_CONFLICT + boolean isSameFileOnRemote = false; + if (mFile != null) { + isSameFileOnRemote = FileUploadHelper.Companion.instance() + .isSameFileOnRemote(user, mFile.getStoragePath(), mRemotePath, mContext); + } + + if (isSameFileOnRemote) { + return new RemoteOperationResult<>(ResultCode.OK); + } else { + return new RemoteOperationResult<>(ResultCode.SYNC_CONFLICT); + } + } + } + + if (mCancellationRequested.get()) { + throw new OperationCancelledException(); + } + + return null; + } + + private void deleteNonExistingFile(File file) { + if (file.exists()) { + return; + } + + Log_OC.d(TAG, "deleting non-existing file from upload list and file list"); + + uploadsStorageManager.removeUpload(mOCUploadId); + + // some chunks can be uploaded and can still exists in db thus we have to remove it as well + getStorageManager().removeFile(mFile, true, true); + } + + private void handleLocalBehaviour(File temporalFile, + File expectedFile, + File originalFile, + OwnCloudClient client) { + + // only LOCAL_BEHAVIOUR_COPY not using original file + if (mLocalBehaviour != FileUploadWorker.LOCAL_BEHAVIOUR_COPY) { + // if file is not exists we should only delete from our app + deleteNonExistingFile(originalFile); + } + + Log_OC.d(TAG, "handling local behaviour for: " + originalFile.getName() + " behaviour: " + mLocalBehaviour); + + switch (mLocalBehaviour) { + case FileUploadWorker.LOCAL_BEHAVIOUR_DELETE: + Log_OC.d(TAG, "DELETE local behaviour will be handled"); + try { + Files.delete(originalFile.toPath()); + } catch (IOException e) { + Log_OC.e(TAG, "Could not delete original file: " + originalFile.getAbsolutePath(), e); + } + mFile.setStoragePath(""); + getStorageManager().deleteFileInMediaScan(originalFile.getAbsolutePath()); + saveUploadedFile(client); + break; + + case FileUploadWorker.LOCAL_BEHAVIOUR_COPY: + Log_OC.d(TAG, "COPY local behaviour will be handled"); + if (temporalFile != null) { + try { + move(temporalFile, expectedFile); + } catch (IOException e) { + Log_OC.e(TAG, e.getMessage()); + + // handling non-existing file for local copy as well + deleteNonExistingFile(temporalFile); + } + } else if (originalFile != null) { + try { + copy(originalFile, expectedFile); + } catch (IOException e) { + Log_OC.e(TAG, e.getMessage()); + } + } + mFile.setStoragePath(expectedFile.getAbsolutePath()); + saveUploadedFile(client); + if (MimeTypeUtil.isMedia(mFile.getMimeType())) { + FileDataStorageManager.triggerMediaScan(expectedFile.getAbsolutePath()); + } + break; + + case FileUploadWorker.LOCAL_BEHAVIOUR_MOVE: + Log_OC.d(TAG, "MOVE local behaviour will be handled"); + String expectedPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mFile); + File newFile = new File(expectedPath); + + try { + move(originalFile, newFile); + } catch (IOException e) { + Log_OC.e(TAG, "Error moving file", e); + } + getStorageManager().deleteFileInMediaScan(originalFile.getAbsolutePath()); + mFile.setStoragePath(newFile.getAbsolutePath()); + saveUploadedFile(client); + if (MimeTypeUtil.isMedia(mFile.getMimeType())) { + FileDataStorageManager.triggerMediaScan(newFile.getAbsolutePath()); + } + break; + + default: + Log_OC.d(TAG, "DEFAULT local behaviour will be handled"); + mFile.setStoragePath(""); + saveUploadedFile(client); + break; + } + } + + private OCCapability getCapabilities() { + return CapabilityUtils.getCapability(mContext); + } + + /** + * Checks the existence of the folder where the current file will be uploaded both in the remote server and in the + * local database. + *

+ * If the upload is set to enforce the creation of the folder, the method tries to create it both remote and + * locally. + * + * @param pathToGrant Full remote path whose existence will be granted. + * @return An {@link OCFile} instance corresponding to the folder where the file will be uploaded. + */ + private RemoteOperationResult grantFolderExistence(String pathToGrant, OwnCloudClient client) { + var operation = new ExistenceCheckRemoteOperation(pathToGrant, false); + var result = operation.execute(client); + if (!result.isSuccess() && result.getCode() == ResultCode.FILE_NOT_FOUND && mRemoteFolderToBeCreated) { + SyncOperation syncOp = new CreateFolderOperation(pathToGrant, user, getContext(), getStorageManager()); + result = syncOp.execute(client); + } + if (result.isSuccess()) { + OCFile parentDir = getStorageManager().getFileByPath(pathToGrant); + if (parentDir == null) { + parentDir = createLocalFolder(pathToGrant); + } + if (parentDir != null) { + result = new RemoteOperationResult<>(ResultCode.OK); + } else { + result = new RemoteOperationResult<>(ResultCode.CANNOT_CREATE_FILE); + } + } + return result; + } + + private OCFile createLocalFolder(String remotePath) { + String parentPath = new File(remotePath).getParent(); + parentPath = parentPath.endsWith(OCFile.PATH_SEPARATOR) ? + parentPath : parentPath + OCFile.PATH_SEPARATOR; + OCFile parent = getStorageManager().getFileByPath(parentPath); + if (parent == null) { + parent = createLocalFolder(parentPath); + } + if (parent != null) { + OCFile createdFolder = new OCFile(remotePath); + createdFolder.setMimeType(MimeType.DIRECTORY); + createdFolder.setParentId(parent.getFileId()); + getStorageManager().saveFile(createdFolder); + return createdFolder; + } + return null; + } + + + /** + * Create a new OCFile mFile with new remote path. This is required if nameCollisionPolicy==RENAME. New file is + * stored as mFile, original as mOldFile. + * + * @param newRemotePath new remote path + */ + private void createNewOCFile(String newRemotePath) { + // a new OCFile instance must be created for a new remote path + OCFile newFile = new OCFile(newRemotePath); + newFile.setCreationTimestamp(mFile.getCreationTimestamp()); + newFile.setFileLength(mFile.getFileLength()); + newFile.setMimeType(mFile.getMimeType()); + newFile.setModificationTimestamp(mFile.getModificationTimestamp()); + newFile.setModificationTimestampAtLastSyncForData( + mFile.getModificationTimestampAtLastSyncForData() + ); + newFile.setEtag(mFile.getEtag()); + newFile.setLastSyncDateForProperties(mFile.getLastSyncDateForProperties()); + newFile.setLastSyncDateForData(mFile.getLastSyncDateForData()); + newFile.setStoragePath(mFile.getStoragePath()); + newFile.setParentId(mFile.getParentId()); + mOldFile = mFile; + mFile = newFile; + } + + /** + * Returns a new and available (does not exists on the server) remotePath. This adds an incremental suffix. + * + * @param client OwnCloud client + * @param remotePath remote path of the file + * @param fileNames list of decrypted file names + * @return new remote path + */ + public static String getNewAvailableRemotePath(OwnCloudClient client, + String remotePath, + List fileNames, + boolean encrypted) { + int extPos = remotePath.lastIndexOf('.'); + String suffix; + String extension = ""; + String remotePathWithoutExtension = ""; + if (extPos >= 0) { + extension = remotePath.substring(extPos + 1); + remotePathWithoutExtension = remotePath.substring(0, extPos); + } + + int count = 2; + boolean exists; + String newPath; + do { + suffix = " (" + count + ")"; + newPath = extPos >= 0 ? remotePathWithoutExtension + suffix + "." + extension : remotePath + suffix; + exists = existsFile(client, newPath, fileNames, encrypted); + count++; + } while (exists); + + return newPath; + } + + private static boolean existsFile(OwnCloudClient client, + String remotePath, + List fileNames, + boolean encrypted) { + if (encrypted) { + String fileName = new File(remotePath).getName(); + + for (String name : fileNames) { + if (name.equalsIgnoreCase(fileName)) { + return true; + } + } + + return false; + } else { + ExistenceCheckRemoteOperation existsOperation = new ExistenceCheckRemoteOperation(remotePath, false); + final var result = existsOperation.execute(client); + return result.isSuccess(); + } + } + + /** + * Cancels the current upload process. + * + *

+ * Behavior depends on the current state of the upload: + *

    + *
  • Upload in preparation: Upload will not start and a cancellation flag is set.
  • + *
  • Upload in progress: The ongoing upload operation is cancelled via + * {@link UploadFileRemoteOperation#cancel(ResultCode)}.
  • + *
  • No upload operation: A cancellation flag is still set, but this situation is unexpected + * and logged as an error.
  • + *
+ * + *

+ * Once cancelled, the database will be updated through + * {@link UploadsStorageManager#updateDatabaseUploadResult(RemoteOperationResult, UploadFileOperation)}. + * + * @param cancellationReason the reason for cancellation + */ + public void cancel(ResultCode cancellationReason) { + if (mUploadOperation != null) { + // Cancel an active upload + Log_OC.d(TAG, "Cancelling upload during actual upload operation."); + mUploadOperation.cancel(cancellationReason); + } else { + // Cancel while preparing or when no upload exists + mCancellationRequested.set(true); + if (mUploadStarted.get()) { + Log_OC.d(TAG, "Cancelling upload during preparation."); + } else { + Log_OC.e(TAG, "No upload in progress. This should not happen."); + } + } + } + + /** + * As soon as this method return true, upload can be cancel via cancel(). + */ + public boolean isUploadInProgress() { + return mUploadStarted.get(); + + } + + /** + * TODO rewrite with homogeneous fail handling, remove dependency on {@link RemoteOperationResult}, + * TODO use Exceptions instead + * + * @param sourceFile Source file to copy. + * @param targetFile Target location to copy the file. + * @return {@link RemoteOperationResult} + * @throws IOException exception if file cannot be accessed + */ + private RemoteOperationResult copy(File sourceFile, File targetFile) throws IOException { + Log_OC.d(TAG, "Copying local file"); + + if (FileStorageUtils.getUsableSpace() < sourceFile.length()) { + return new RemoteOperationResult(ResultCode.LOCAL_STORAGE_FULL); // error when the file should be copied + } else { + Log_OC.d(TAG, "Creating temporal folder"); + File temporalParent = targetFile.getParentFile(); + + if (!temporalParent.mkdirs() && !temporalParent.isDirectory()) { + return new RemoteOperationResult(ResultCode.CANNOT_CREATE_FILE); + } + + Log_OC.d(TAG, "Creating temporal file"); + if (!targetFile.createNewFile() && !targetFile.isFile()) { + return new RemoteOperationResult(ResultCode.CANNOT_CREATE_FILE); + } + + Log_OC.d(TAG, "Copying file contents"); + InputStream in = null; + OutputStream out = null; + + try { + if (!mOriginalStoragePath.equals(targetFile.getAbsolutePath())) { + // In case document provider schema as 'content://' + if (mOriginalStoragePath.startsWith(UriUtils.URI_CONTENT_SCHEME)) { + Uri uri = Uri.parse(mOriginalStoragePath); + in = mContext.getContentResolver().openInputStream(uri); + } else { + in = new FileInputStream(sourceFile); + } + out = new FileOutputStream(targetFile); + int nRead; + byte[] buf = new byte[4096]; + while (!mCancellationRequested.get() && + (nRead = in.read(buf)) > -1) { + out.write(buf, 0, nRead); + } + out.flush(); + + } // else: weird but possible situation, nothing to copy + + if (mCancellationRequested.get()) { + return new RemoteOperationResult(new OperationCancelledException()); + } + } catch (Exception e) { + return new RemoteOperationResult(ResultCode.LOCAL_STORAGE_NOT_COPIED); + } finally { + try { + if (in != null) { + in.close(); + } + } catch (Exception e) { + Log_OC.d(TAG, "Weird exception while closing input stream for " + + mOriginalStoragePath + " (ignoring)", e); + } + try { + if (out != null) { + out.close(); + } + } catch (Exception e) { + Log_OC.d(TAG, "Weird exception while closing output stream for " + + targetFile.getAbsolutePath() + " (ignoring)", e); + } + } + } + return new RemoteOperationResult(ResultCode.OK); + } + + + /** + * TODO rewrite with homogeneous fail handling, remove dependency on {@link RemoteOperationResult}, + * TODO use Exceptions instead + * TODO refactor both this and 'copy' in a single method + * + * @param sourceFile Source file to move. + * @param targetFile Target location to move the file. + * @throws IOException exception if file cannot be read/wrote + */ + private void move(File sourceFile, File targetFile) throws IOException { + + if (!targetFile.equals(sourceFile)) { + File expectedFolder = targetFile.getParentFile(); + Files.createDirectories(expectedFolder.toPath()); + + if (expectedFolder.isDirectory()) { + if (!sourceFile.renameTo(targetFile)) { + // try to copy and then delete + Files.createFile(targetFile.toPath()); + try ( + FileChannel inChannel = new FileInputStream(sourceFile).getChannel(); + FileChannel outChannel = new FileOutputStream(targetFile).getChannel() + ) { + inChannel.transferTo(0, inChannel.size(), outChannel); + Files.delete(sourceFile.toPath()); + } catch (Exception e) { + mFile.setStoragePath(""); // forget the local file + // by now, treat this as a success; the file was uploaded + // the best option could be show a warning message + } + } + + } else { + mFile.setStoragePath(""); + } + } + } + + /** + * Saves a OC File after a successful upload. + *

+ * A PROPFIND is necessary to keep the props in the local database synchronized with the server, specially the + * modification time and Etag (where available) + */ + private void saveUploadedFile(OwnCloudClient client) { + OCFile file = mFile; + if (file.fileExists()) { + file = getStorageManager().getFileById(file.getFileId()); + } + if (file == null) { + // this can happen e.g. when the file gets deleted during upload + return; + } + long syncDate = System.currentTimeMillis(); + file.setLastSyncDateForData(syncDate); + + // new PROPFIND to keep data consistent with server + // in theory, should return the same we already have + // TODO from the appropriate OC server version, get data from last PUT response headers, instead + // TODO of a new PROPFIND; the latter may fail, specially for chunked uploads + String path; + if (encryptedAncestor) { + path = file.getParentRemotePath() + mFile.getEncryptedFileName(); + } else { + path = getRemotePath(); + } + + ReadFileRemoteOperation operation = new ReadFileRemoteOperation(path); + RemoteOperationResult result = operation.execute(client); + if (result.isSuccess()) { + updateOCFile(file, (RemoteFile) result.getData().get(0)); + file.setLastSyncDateForProperties(syncDate); + } else { + Log_OC.e(TAG, "Error reading properties of file after successful upload; this is gonna hurt..."); + } + + if (mWasRenamed) { + OCFile oldFile = getStorageManager().getFileByPath(mOldFile.getRemotePath()); + if (oldFile != null) { + oldFile.setStoragePath(null); + getStorageManager().saveFile(oldFile); + getStorageManager().saveConflict(oldFile, null); + } + // else: it was just an automatic renaming due to a name + // coincidence; nothing else is needed, the storagePath is right + // in the instance returned by mCurrentUpload.getFile() + } + file.setUpdateThumbnailNeeded(true); + getStorageManager().saveFile(file); + getStorageManager().saveConflict(file, null); + + if (MimeTypeUtil.isMedia(file.getMimeType())) { + FileDataStorageManager.triggerMediaScan(file.getStoragePath(), file); + } + + // generate new Thumbnail + final ThumbnailsCacheManager.ThumbnailGenerationTask task = + new ThumbnailsCacheManager.ThumbnailGenerationTask(getStorageManager(), user); + task.execute(new ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, file.getRemoteId())); + } + + private void updateOCFile(OCFile file, RemoteFile remoteFile) { + file.setCreationTimestamp(remoteFile.getCreationTimestamp()); + file.setFileLength(remoteFile.getLength()); + file.setMimeType(remoteFile.getMimeType()); + file.setModificationTimestamp(remoteFile.getModifiedTimestamp()); + file.setModificationTimestampAtLastSyncForData(remoteFile.getModifiedTimestamp()); + file.setEtag(remoteFile.getEtag()); + file.setRemoteId(remoteFile.getRemoteId()); + file.setPermissions(remoteFile.getPermissions()); + file.setUploadTimestamp(remoteFile.getUploadTimestamp()); + } + + public interface OnRenameListener { + + void onRenameUpload(); + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/common/SyncOperation.java b/app/src/main/java/com/owncloud/android/operations/common/SyncOperation.java new file mode 100644 index 000000000000..27dfbd18efe3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/common/SyncOperation.java @@ -0,0 +1,77 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Chris Narkiewicz + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.operations.common; + +import android.content.Context; +import android.os.Handler; + +import com.nextcloud.common.NextcloudClient; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; + +import androidx.annotation.NonNull; + +/** + * Operation which execution involves both interactions with an ownCloud server and with local data in the device. + *

+ * Provides methods to execute the operation both synchronously or asynchronously. + */ +public abstract class SyncOperation extends RemoteOperation { + private final FileDataStorageManager storageManager; + + public SyncOperation(@NonNull FileDataStorageManager storageManager) { + this.storageManager = storageManager; + } + + /** + * Synchronously executes the operation on the received ownCloud account. + *

+ * Do not call this method from the main thread. + *

+ * This method should be used whenever an ownCloud account is available, instead of {@link + * #execute(OwnCloudClient)}. + * + * @param context Android context for the component calling the method. + * @return Result of the operation. + */ + public RemoteOperationResult execute(Context context) { + if (storageManager.getUser().isAnonymous()) { + return new RemoteOperationResult(RemoteOperationResult.ResultCode.ACCOUNT_EXCEPTION); + } + return super.execute(this.storageManager.getUser(), context); + } + + public RemoteOperationResult execute(@NonNull NextcloudClient client) { + return run(client); + } + + /** + * Asynchronously executes the remote operation + * + * @param client Client object to reach an ownCloud server during the + * execution of the operation. + * @param listener Listener to be notified about the execution of the operation. + * @param listenerHandler Handler associated to the thread where the methods of + * the listener objects must be called. + * @return Thread were the remote operation is executed. + */ + public Thread execute(OwnCloudClient client, + OnRemoteOperationListener listener, + Handler listenerHandler) { + return super.execute(client, listener, listenerHandler); + } + + public FileDataStorageManager getStorageManager() { + return this.storageManager; + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/e2e/E2EClientData.kt b/app/src/main/java/com/owncloud/android/operations/e2e/E2EClientData.kt new file mode 100644 index 000000000000..892152a5163c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2EClientData.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.e2e + +import com.owncloud.android.lib.common.OwnCloudClient + +data class E2EClientData(val client: OwnCloudClient, val token: String, val publicKey: String) diff --git a/app/src/main/java/com/owncloud/android/operations/e2e/E2EData.kt b/app/src/main/java/com/owncloud/android/operations/e2e/E2EData.kt new file mode 100644 index 000000000000..003d216a6646 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2EData.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.e2e + +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFile + +data class E2EData( + val key: ByteArray, + val iv: ByteArray, + val encryptedFile: EncryptedFile, + val encryptedFileName: String +) diff --git a/app/src/main/java/com/owncloud/android/operations/e2e/E2EFiles.kt b/app/src/main/java/com/owncloud/android/operations/e2e/E2EFiles.kt new file mode 100644 index 000000000000..aee43b97f19e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2EFiles.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.e2e + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import java.io.File + +data class E2EFiles( + var parentFile: OCFile, + var temporalFile: File?, + var originalFile: File, + var expectedFile: File?, + var encryptedTempFile: File? +) { + private val tag = "E2EFiles" + + fun deleteTemporalFile() { + if (temporalFile?.exists() == true && temporalFile?.delete() == false) { + Log_OC.e(tag, "Could not delete temporal file " + temporalFile?.absolutePath) + } + } + + fun deleteTemporalFileWithOriginalFileComparison() { + if (originalFile == temporalFile) { + return + } + + val isTemporalFileDeleted = temporalFile?.delete() + Log_OC.d(tag, "isTemporalFileDeleted: $isTemporalFileDeleted") + } + + fun deleteEncryptedTempFile() { + if (encryptedTempFile != null) { + val isTempEncryptedFileDeleted = encryptedTempFile?.delete() + Log_OC.d(tag, "isTempEncryptedFileDeleted: $isTempEncryptedFileDeleted") + } else { + Log_OC.e(tag, "Encrypted temp file cannot be found") + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt b/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt new file mode 100644 index 000000000000..9fc0d558710b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.factory + +import android.content.Context +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.network.ConnectivityService +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.operations.UploadFileOperation +import javax.inject.Inject + +@Suppress("LongParameterList") +class UploadFileOperationFactory @Inject constructor( + private val uploadsStorageManager: UploadsStorageManager, + private val connectivityService: ConnectivityService, + private val powerManagementService: PowerManagementService, + private val context: Context, + private val accountManager: UserAccountManager, + private val fileDataStorageManager: FileDataStorageManager +) { + + fun create( + upload: OCUpload, + progressListener: OnDatatransferProgressListener? = null, + disableRetries: Boolean = true + ): UploadFileOperation = UploadFileOperation( + uploadsStorageManager, + connectivityService, + powerManagementService, + accountManager.user, + null, + upload, + upload.nameCollisionPolicy ?: NameCollisionPolicy.ASK_USER, + upload.localAction, + context, + upload.isUseWifiOnly, + upload.isWhileChargingOnly, + disableRetries, + fileDataStorageManager + ).apply { + progressListener?.let { addDataTransferProgressListener(it) } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiver.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiver.kt new file mode 100644 index 000000000000..2d35262d0ee1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiver.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.core.content.IntentCompat +import androidx.core.net.toUri +import com.owncloud.android.operations.UploadFileOperation + +class UploadFileBroadcastReceiver : BroadcastReceiver() { + companion object { + const val ACTION_TYPE = "UploadFileBroadcastReceiver.ACTION_TYPE" + } + + override fun onReceive(context: Context, intent: Intent) { + val actionType = + IntentCompat.getSerializableExtra(intent, ACTION_TYPE, UploadFileBroadcastReceiverActions::class.java) + ?: return + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(UploadFileOperation.MISSING_FILE_PERMISSION_NOTIFICATION_ID) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + actionType == UploadFileBroadcastReceiverActions.ALLOW_ALL_FILES + ) { + redirectToAllFilesAccess(context) + } else { + redirectToAppInfo(context) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun redirectToAllFilesAccess(context: Context) { + Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = "package:${context.packageName}".toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }.run { + context.startActivity(this) + } + } + + private fun redirectToAppInfo(context: Context) { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }.run { + context.startActivity(this) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiverActions.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiverActions.kt new file mode 100644 index 000000000000..c0d00d4279ae --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileBroadcastReceiverActions.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.upload + +enum class UploadFileBroadcastReceiverActions : java.io.Serializable { + ALLOW_ALL_FILES, + APP_PERMISSIONS +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt new file mode 100644 index 000000000000..880a5ebbfca1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.upload + +sealed class UploadFileException(message: String) : Exception(message) { + class EmptyOrNullFilePath : UploadFileException("Empty or null file path") + class MissingPermission : UploadFileException("Missing storage permission") +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileOperationExtensions.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileOperationExtensions.kt new file mode 100644 index 000000000000..72ba621457bf --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileOperationExtensions.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.owncloud.android.R +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.UploadFileOperation.MISSING_FILE_PERMISSION_NOTIFICATION_ID +import com.owncloud.android.ui.notifications.NotificationUtils + +fun UploadFileOperation.showStoragePermissionNotification() { + val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java) + ?: return + val alreadyShown = notificationManager.activeNotifications.any { + it.id == MISSING_FILE_PERMISSION_NOTIFICATION_ID + } + if (alreadyShown) { + return + } + + val allowAllFileAccessAction = getAllowAllFileAccessAction(context) + val appPermissionsAction = getAppPermissionsAction(context) + + val notificationBuilder = + NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD) + .setSmallIcon(android.R.drawable.stat_sys_warning) + .setContentTitle(context.getString(R.string.upload_missing_storage_permission_title)) + .setContentText(context.getString(R.string.upload_missing_storage_permission_description)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .addAction(allowAllFileAccessAction) + .addAction(appPermissionsAction) + .setAutoCancel(true) + + notificationManager.notify(MISSING_FILE_PERMISSION_NOTIFICATION_ID, notificationBuilder.build()) +} + +private fun getActionPendingIntent(context: Context, actionType: UploadFileBroadcastReceiverActions): PendingIntent { + val intent = Intent(context, UploadFileBroadcastReceiver::class.java).apply { + action = "com.owncloud.android.ACTION_UPLOAD_FILE_PERMISSION" + putExtra(UploadFileBroadcastReceiver.ACTION_TYPE, actionType) + } + + return PendingIntent.getBroadcast( + context, + actionType.ordinal, + intent, + PendingIntent.FLAG_IMMUTABLE + ) +} + +private fun getAllowAllFileAccessAction(context: Context): NotificationCompat.Action { + val pendingIntent = getActionPendingIntent(context, UploadFileBroadcastReceiverActions.ALLOW_ALL_FILES) + return NotificationCompat.Action( + null, + context.getString(R.string.upload_missing_storage_permission_allow_file_access), + pendingIntent + ) +} + +private fun getAppPermissionsAction(context: Context): NotificationCompat.Action { + val pendingIntent = getActionPendingIntent(context, UploadFileBroadcastReceiverActions.APP_PERMISSIONS) + return NotificationCompat.Action( + null, + context.getString(R.string.upload_missing_storage_permission_app_permissions), + pendingIntent + ) +} diff --git a/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java b/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java new file mode 100644 index 000000000000..2ec6a718abbc --- /dev/null +++ b/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java @@ -0,0 +1,139 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.providers; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.owncloud.android.MainApp; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.lib.common.utils.Log_OC; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.nio.file.Files; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import dagger.android.AndroidInjection; + +public class DiskLruImageCacheFileProvider extends ContentProvider { + public static final String TAG = DiskLruImageCacheFileProvider.class.getSimpleName(); + + @Inject + protected UserAccountManager accountManager; + + @Override + public boolean onCreate() { + AndroidInjection.inject(this); + return true; + } + + private OCFile getFile(Uri uri) { + User user = accountManager.getUser(); + FileDataStorageManager fileDataStorageManager = new FileDataStorageManager(user, + MainApp.getAppContext().getContentResolver()); + + return fileDataStorageManager.getFileByPath(uri.getPath()); + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + return getParcelFileDescriptorForOCFile(getFile(uri)); + } + + public static ParcelFileDescriptor getParcelFileDescriptorForOCFile(OCFile ocFile) throws FileNotFoundException { + Bitmap thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE + ocFile.getRemoteId()); + + // fallback to thumbnail + if (thumbnail == null) { + thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_THUMBNAIL + ocFile.getRemoteId()); + } + + // fallback to default image + if (thumbnail == null) { + thumbnail = ThumbnailsCacheManager.mDefaultImg; + } + + // create a file to write bitmap data + File f = new File(MainApp.getAppContext().getCacheDir(), ocFile.getFileName()); + try { + Files.createFile(f.toPath()); + + //Convert bitmap to byte array + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + thumbnail.compress(Bitmap.CompressFormat.PNG, 90, bos); + byte[] bitmapData = bos.toByteArray(); + + //write the bytes in file + try (FileOutputStream fos = new FileOutputStream(f)){ + fos.write(bitmapData); + } catch (FileNotFoundException e) { + Log_OC.e(TAG, "File not found: " + e.getMessage()); + } + + } catch (Exception e) { + Log_OC.e(TAG, "Error opening file: " + e.getMessage()); + } + + return ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY); + } + + @Override + public String getType(@NonNull Uri uri) { + OCFile ocFile = getFile(uri); + return ocFile.getMimeType(); + } + + @Override + public Cursor query(@NonNull Uri uri, String[] arg1, String arg2, String[] arg3, String arg4) { + MatrixCursor cursor = null; + + OCFile ocFile = getFile(uri); + File file = new File(MainApp.getAppContext().getCacheDir(), ocFile.getFileName()); + if (file.exists()) { + cursor = new MatrixCursor(new String[] { + OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }); + cursor.addRow(new Object[] { uri.getLastPathSegment(), + file.length() }); + } + + return cursor; + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } +} diff --git a/app/src/main/java/com/owncloud/android/providers/DocumentsStorageProvider.java b/app/src/main/java/com/owncloud/android/providers/DocumentsStorageProvider.java new file mode 100644 index 000000000000..3ad48e4eaaab --- /dev/null +++ b/app/src/main/java/com/owncloud/android/providers/DocumentsStorageProvider.java @@ -0,0 +1,928 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019-2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2016 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.providers; + +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.graphics.Point; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.provider.DocumentsProvider; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.nextcloud.client.utils.HashUtil; +import com.nextcloud.utils.extensions.ContextExtensionsKt; +import com.nextcloud.utils.fileNameValidator.FileNameValidator; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.CheckEtagRemoteOperation; +import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.operations.CopyFileOperation; +import com.owncloud.android.operations.CreateFolderOperation; +import com.owncloud.android.operations.DownloadFileOperation; +import com.owncloud.android.operations.MoveFileOperation; +import com.owncloud.android.operations.RefreshFolderOperation; +import com.owncloud.android.operations.RemoveFileOperation; +import com.owncloud.android.operations.RenameFileOperation; +import com.owncloud.android.ui.activity.SettingsActivity; +import com.owncloud.android.ui.helpers.FileOperationsHelper; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.FileUtil; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.theme.CapabilityUtils; + +import org.nextcloud.providers.cursors.FileCursor; +import org.nextcloud.providers.cursors.RootCursor; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import dagger.android.AndroidInjection; + +import static android.os.ParcelFileDescriptor.MODE_READ_ONLY; +import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY; +import static com.owncloud.android.datamodel.OCFile.PATH_SEPARATOR; +import static com.owncloud.android.datamodel.OCFile.ROOT_PATH; + +public class DocumentsStorageProvider extends DocumentsProvider { + + private static final String TAG = DocumentsStorageProvider.class.getSimpleName(); + + private static final long CACHE_EXPIRATION = TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES); + + @Inject UserAccountManager accountManager; + + private boolean isFolderPathValid = true; + + @VisibleForTesting + static final String DOCUMENTID_SEPARATOR = "/"; + private static final int DOCUMENTID_PARTS = 2; + private final Map rootIdToStorageManager = new HashMap<>(); + + private final Executor executor = Executors.newCachedThreadPool(); + + @Override + public Cursor queryRoots(String[] projection) { + + // always recreate storage manager collection, as it will change after account creation/removal + // and we need to serve document(tree)s with persist permissions + initiateStorageMap(); + + Context context = MainApp.getAppContext(); + AppPreferences preferences = AppPreferencesImpl.fromContext(context); + if (SettingsActivity.LOCK_PASSCODE.equals(preferences.getLockPreference()) || + SettingsActivity.LOCK_DEVICE_CREDENTIALS.equals(preferences.getLockPreference())) { + return new FileCursor(); + } + + final RootCursor result = new RootCursor(projection); + for(FileDataStorageManager manager: rootIdToStorageManager.values()) { + result.addRoot(new Document(manager, ROOT_PATH), getContext()); + } + + return result; + } + + public static void notifyRootsChanged(Context context) { + String authority = context.getString(R.string.document_provider_authority); + Uri rootsUri = DocumentsContract.buildRootsUri(authority); + context.getContentResolver().notifyChange(rootsUri, null); + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + Log_OC.d(TAG, "queryDocument(), id=" + documentId); + + Document document = toDocument(documentId); + + final FileCursor result = new FileCursor(projection); + result.addFile(document); + + return result; + } + + @SuppressLint("LongLogTag") + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) + throws FileNotFoundException { + Log_OC.d(TAG, "queryChildDocuments(), id=" + parentDocumentId); + + Context context = getNonNullContext(); + Document parentFolder = toDocument(parentDocumentId); + final FileCursor resultCursor = new FileCursor(projection); + + if (!parentFolder.getFile().canRead()) { + showToast(R.string.document_storage_provider_cannot_read); + return resultCursor; + } + + if (parentFolder.getFile().isEncrypted() && + !FileOperationsHelper.isEndToEndEncryptionSetup(context, parentFolder.getUser())) { + showToast(R.string.e2e_not_yet_setup); + return resultCursor; + } + + FileDataStorageManager storageManager = parentFolder.getStorageManager(); + + for (OCFile file : storageManager.getFolderContent(parentFolder.getFile(), false)) { + if (file.canRead()) { + resultCursor.addFile(new Document(storageManager, file)); + } else { + Log_OC.w(TAG,"Skipping file, doesn't have read permission. RemotePath: " + file.getRemotePath()); + } + } + + boolean isLoading = false; + if (parentFolder.isExpired()) { + final ReloadFolderDocumentTask task = new ReloadFolderDocumentTask(parentFolder, result -> + context.getContentResolver().notifyChange(toNotifyUri(parentFolder), null, false)); + task.executeOnExecutor(executor); + resultCursor.setLoadingTask(task); + isLoading = true; + } + + final Bundle extra = new Bundle(); + extra.putBoolean(DocumentsContract.EXTRA_LOADING, isLoading); + resultCursor.setExtras(extra); + resultCursor.setNotificationUri(context.getContentResolver(), toNotifyUri(parentFolder)); + return resultCursor; + } + + @SuppressLint("LongLogTag") + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal cancellationSignal) + throws FileNotFoundException { + Log_OC.d(TAG, "openDocument(), id=" + documentId); + + if (!isFolderPathValid) { + Log_OC.d(TAG, "Folder path is not valid, operation is cancelled"); + return null; + } + + Document document = toDocument(documentId); + Context context = getNonNullContext(); + + OCFile ocFile = document.getFile(); + User user = document.getUser(); + + int accessMode = ParcelFileDescriptor.parseMode(mode); + boolean writeOnly = (accessMode & MODE_WRITE_ONLY) != 0; + boolean needsDownload = !ocFile.existsOnDevice() || (!writeOnly && hasServerChange(document)); + if (needsDownload) { + if (ocFile.getLocalModificationTimestamp() > ocFile.getLastSyncDateForData()) { + // TODO show a conflict notification with a pending intent that shows a ConflictResolveDialog + Log_OC.w(TAG, "Conflict found!"); + } else { + // dirty threading workaround for client apps which call openDocument on the main thread, thus causing + // a NetworkOnMainThreadException + final AtomicBoolean downloadResult = new AtomicBoolean(false); + final Thread downloadThread = new Thread(() -> { + DownloadFileOperation downloadFileOperation = new DownloadFileOperation(user, ocFile, context); + final var result = downloadFileOperation.execute(document.getClient()); + if (!result.isSuccess()) { + if (ocFile.isDown()) { + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(() -> showToast(R.string.file_not_synced)); + downloadResult.set(true); + } else { + Log_OC.e(TAG, result.toString()); + } + } else { + saveDownloadedFile(document.getStorageManager(), downloadFileOperation, ocFile); + downloadResult.set(true); + } + }); + downloadThread.start(); + + try { + downloadThread.join(); + if (!downloadResult.get()) { + throw new FileNotFoundException("Error downloading file: " + ocFile.getFileName()); + } + } catch (InterruptedException e) { + throw new FileNotFoundException("Error downloading file: " + ocFile.getFileName()); + } + } + } + + File file = new File(ocFile.getStoragePath()); + + if (accessMode != MODE_READ_ONLY) { + // The calling thread is not guaranteed to have a Looper, so we can't block it with the OnCloseListener. + // Thus, we are unable to do a synchronous upload and have to start an asynchronous one. + Handler handler = new Handler(context.getMainLooper()); + try { + return ParcelFileDescriptor.open(file, accessMode, handler, error -> { + if (error == null) { + // no error + // As we can't upload the file synchronously, let's at least update its metadata here already. + ocFile.setFileLength(file.length()); + ocFile.setModificationTimestamp(System.currentTimeMillis()); + document.getStorageManager().saveFile(ocFile); + + // TODO disable upload notifications as DocumentsProvider users already show them + // upload file with FileUploader service (off main thread) + FileUploadHelper.Companion.instance().uploadUpdatedFile( + user, + new OCFile[]{ ocFile }, + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, + NameCollisionPolicy.OVERWRITE); + } else { + // error, no upload needed + Log_OC.e(TAG, "File was closed with an error: " + ocFile.getFileName(), error); + } + }); + } catch (IOException e) { + throw new FileNotFoundException("Failed to open document for writing " + ocFile.getFileName()); + } + } else { + return ParcelFileDescriptor.open(file, accessMode); + } + } + + private boolean hasServerChange(Document document) throws FileNotFoundException { + Context context = getNonNullContext(); + OCFile ocFile = document.getFile(); + RemoteOperationResult result = new CheckEtagRemoteOperation(ocFile.getRemotePath(), ocFile.getEtag()) + .execute(document.getUser(), context); + return switch (result.getCode()) { + case ETAG_CHANGED -> result.getData() != null; + case ETAG_UNCHANGED -> false; + default -> { + Log_OC.e(TAG, result.toString()); + throw new FileNotFoundException("Error synchronizing file: " + ocFile.getFileName()); + } + }; + } + + /** + * Updates the OC File after a successful download. + * + */ + private void saveDownloadedFile(FileDataStorageManager storageManager, DownloadFileOperation dfo, OCFile file) { + long syncDate = System.currentTimeMillis(); + file.setLastSyncDateForProperties(syncDate); + file.setLastSyncDateForData(syncDate); + file.setUpdateThumbnailNeeded(true); + file.setModificationTimestamp(dfo.getModificationTimestamp()); + file.setModificationTimestampAtLastSyncForData(dfo.getModificationTimestamp()); + file.setEtag(dfo.getEtag()); + file.setMimeType(dfo.getMimeType()); + String savePath = dfo.getSavePath(); + file.setStoragePath(savePath); + file.setFileLength(new File(savePath).length()); + file.setRemoteId(dfo.getFile().getRemoteId()); + storageManager.saveFile(file); + if (MimeTypeUtil.isMedia(dfo.getMimeType())) { + FileDataStorageManager.triggerMediaScan(file.getStoragePath(), file); + } + storageManager.saveConflict(file, null); + } + + @Override + public boolean onCreate() { + AndroidInjection.inject(this); + + // initiate storage manager collection, because we need to serve document(tree)s + // with persist permissions + initiateStorageMap(); + + return true; + } + + @Override + public AssetFileDescriptor openDocumentThumbnail(String documentId, + Point sizeHint, + CancellationSignal signal) + throws FileNotFoundException { + Log_OC.d(TAG, "openDocumentThumbnail(), id=" + documentId); + + Document document = toDocument(documentId); + OCFile file = document.getFile(); + + boolean exists = ThumbnailsCacheManager.containsBitmap(ThumbnailsCacheManager.PREFIX_THUMBNAIL + + file.getRemoteId()); + if (!exists) { + ThumbnailsCacheManager.generateThumbnailFromOCFile(file, document.getUser(), getContext()); + } + + return new AssetFileDescriptor(DiskLruImageCacheFileProvider.getParcelFileDescriptorForOCFile(file), + 0, + file.getFileLength()); + } + + @Override + public String renameDocument(String documentId, String displayName) throws FileNotFoundException { + Log_OC.d(TAG, "renameDocument(), id=" + documentId); + + String errorMessage = checkFileName(displayName); + if (errorMessage != null) { + showToast(errorMessage); + return null; + } + + Document document = toDocument(documentId); + if (!document.getFile().canRename()) { + showToast(R.string.document_storage_provider_cannot_rename); + return null; + } + + final var result = new RenameFileOperation(document.getRemotePath(), + displayName, + document.getStorageManager()) + .execute(document.getClient()); + + if (!result.isSuccess()) { + Log_OC.e(TAG, result.toString()); + throw new FileNotFoundException("Failed to rename document with documentId " + documentId + ": " + + result.getException()); + } + + Context context = getNonNullContext(); + context.getContentResolver().notifyChange(toNotifyUri(document.getParent()), null, false); + + return null; + } + + @Override + public String copyDocument(String sourceDocumentId, String targetParentDocumentId) throws FileNotFoundException { + Log_OC.d(TAG, "copyDocument(), id=" + sourceDocumentId); + + Document targetFolder = toDocument(targetParentDocumentId); + + String filename = targetFolder.getFile().getFileName(); + isFolderPathValid = checkFolderPath(filename); + if (!isFolderPathValid) { + showToast(R.string.file_name_validator_error_contains_reserved_names_or_invalid_characters); + return null; + } + + Document document = toDocument(sourceDocumentId); + FileDataStorageManager storageManager = document.getStorageManager(); + final var result = new CopyFileOperation(document.getRemotePath(), + targetFolder.getRemotePath(), + document.getStorageManager()) + .execute(document.getClient()); + + if (!result.isSuccess()) { + Log_OC.e(TAG, result.toString()); + throw new FileNotFoundException("Failed to copy document with documentId " + sourceDocumentId + + " to " + targetParentDocumentId); + } + + Context context = getNonNullContext(); + User user = document.getUser(); + + final var updateParent = new RefreshFolderOperation(targetFolder.getFile(), + System.currentTimeMillis(), + false, + false, + true, + storageManager, + user, + context) + .execute(targetFolder.getClient()); + + if (!updateParent.isSuccess()) { + Log_OC.e(TAG, updateParent.toString()); + throw new FileNotFoundException("Failed to copy document with documentId " + sourceDocumentId + + " to " + targetParentDocumentId); + } + + String newPath = targetFolder.getRemotePath() + document.getFile().getFileName(); + + if (document.getFile().isFolder()) { + newPath = newPath + PATH_SEPARATOR; + } + Document newFile = new Document(storageManager, newPath); + + context.getContentResolver().notifyChange(toNotifyUri(targetFolder), null, false); + + return newFile.getDocumentId(); + } + + @Override + public String moveDocument(String sourceDocumentId, String sourceParentDocumentId, String targetParentDocumentId) + throws FileNotFoundException { + Log_OC.d(TAG, "moveDocument(), id=" + sourceDocumentId); + + Document targetFolder = toDocument(targetParentDocumentId); + + String filename = targetFolder.getFile().getFileName(); + isFolderPathValid = checkFolderPath(filename); + if (!isFolderPathValid) { + showToast(R.string.file_name_validator_error_contains_reserved_names_or_invalid_characters); + return null; + } + + Document document = toDocument(sourceDocumentId); + if (!document.getFile().canMove()) { + showToast(R.string.document_storage_provider_cannot_move); + return null; + } + + final var result = new MoveFileOperation(document.getRemotePath(), + targetFolder.getRemotePath(), + document.getStorageManager()) + .execute(document.getClient()); + + if (!result.isSuccess()) { + Log_OC.e(TAG, result.toString()); + throw new FileNotFoundException("Failed to move document with documentId " + sourceDocumentId + + " to " + targetParentDocumentId); + } + + Document sourceFolder = toDocument(sourceParentDocumentId); + + Context context = getNonNullContext(); + context.getContentResolver().notifyChange(toNotifyUri(sourceFolder), null, false); + context.getContentResolver().notifyChange(toNotifyUri(targetFolder), null, false); + + return sourceDocumentId; + } + + @Override + public Cursor querySearchDocuments(String rootId, String query, String[] projection) { + Log_OC.d(TAG, "querySearchDocuments(), rootId=" + rootId); + + FileCursor result = new FileCursor(projection); + + FileDataStorageManager storageManager = getStorageManager(rootId); + if (storageManager == null) { + return result; + } + + for (Document d : findFiles(new Document(storageManager, ROOT_PATH), query)) { + result.addFile(d); + } + + return result; + } + + private OCCapability getCapabilities() { + return CapabilityUtils.getCapability(accountManager.getUser(), getNonNullContext()); + } + + private boolean checkFolderPath(String filename) { + return FileNameValidator.INSTANCE.checkFolderPath(filename, getCapabilities(), getNonNullContext()); + } + + private String checkFileName(String filename) { + return FileNameValidator.INSTANCE.checkFileName(filename, getCapabilities(), getNonNullContext(),null); + } + + @Override + public String createDocument(String documentId, String mimeType, String displayName) throws FileNotFoundException { + Log_OC.d(TAG, "createDocument(), id=" + documentId); + + String errorMessage = checkFileName(displayName); + if (errorMessage != null) { + showToast(errorMessage); + return null; + } + + Document folderDocument = toDocument(documentId); + if (!folderDocument.getFile().canCreateFileAndFolder()) { + showToast(R.string.document_storage_provider_cannot_create_file_and_folder); + return null; + } + + if (DocumentsContract.Document.MIME_TYPE_DIR.equalsIgnoreCase(mimeType)) { + return createFolder(folderDocument, displayName); + } else { + return createFile(folderDocument, displayName, mimeType); + } + } + + private String createFolder(Document targetFolder, String displayName) throws FileNotFoundException { + if (!targetFolder.getFile().canCreateFileAndFolder()) { + showToast(R.string.document_storage_provider_cannot_create_folder_inside_folder); + return null; + } + + Context context = getNonNullContext(); + String newDirPath = targetFolder.getRemotePath() + displayName + PATH_SEPARATOR; + FileDataStorageManager storageManager = targetFolder.getStorageManager(); + + final var result = new CreateFolderOperation(newDirPath, + accountManager.getUser(), + context, + storageManager) + .execute(targetFolder.getClient()); + + if (!result.isSuccess()) { + Log_OC.e(TAG, result.toString()); + throw new FileNotFoundException("Failed to create document with name " + + displayName + " and documentId " + targetFolder.getDocumentId()); + } + + final var updateParent = new RefreshFolderOperation(targetFolder.getFile(), System.currentTimeMillis(), + false, false, true, storageManager, + targetFolder.getUser(), context) + .execute(targetFolder.getClient()); + + if (!updateParent.isSuccess()) { + Log_OC.e(TAG, updateParent.toString()); + throw new FileNotFoundException("Failed to create document with documentId " + targetFolder.getDocumentId()); + } + + Document newFolder = new Document(storageManager, newDirPath); + + context.getContentResolver().notifyChange(toNotifyUri(targetFolder), null, false); + + return newFolder.getDocumentId(); + } + + private String createFile(Document targetFolder, String displayName, String mimeType) throws FileNotFoundException { + if (!targetFolder.getFile().canCreateFileAndFolder()) { + showToast(R.string.document_storage_provider_cannot_create_file_inside_folder); + return null; + } + + User user = targetFolder.getUser(); + + // create dummy file + File tempDir = new File(FileStorageUtils.getTemporalPath(user.getAccountName())); + + if (!tempDir.exists() && !tempDir.mkdirs()) { + throw new FileNotFoundException("Temp folder could not be created: " + tempDir.getAbsolutePath()); + } + + File emptyFile = new File(tempDir, displayName); + + if (emptyFile.exists() && !emptyFile.delete()) { + throw new FileNotFoundException("Previous file could not be deleted"); + } + + try { + if (!emptyFile.createNewFile()) { + throw new FileNotFoundException("File could not be created"); + } + } catch (IOException e) { + throw getFileNotFoundExceptionWithCause("File could not be created", e); + } + + String newFilePath = targetFolder.getRemotePath() + displayName; + + // FIXME we need to update the mimeType somewhere else as well + + // perform the upload, no need for chunked operation as we have a empty file + OwnCloudClient client = targetFolder.getClient(); + final var result = new UploadFileRemoteOperation(emptyFile.getAbsolutePath(), + newFilePath, + mimeType, + "", + System.currentTimeMillis() / 1000, + FileUtil.getCreationTimestamp(emptyFile), + false) + .execute(client); + + if (!result.isSuccess()) { + Log_OC.e(TAG, result.toString()); + throw new FileNotFoundException("Failed to upload document with path " + newFilePath); + } + + Context context = getNonNullContext(); + + final var updateParent = new RefreshFolderOperation(targetFolder.getFile(), + System.currentTimeMillis(), + false, + false, + true, + targetFolder.getStorageManager(), + user, + context) + .execute(client); + + if (!updateParent.isSuccess()) { + Log_OC.e(TAG, updateParent.toString()); + throw new FileNotFoundException("Failed to create document with documentId " + targetFolder.getDocumentId()); + } + + Document newFile = new Document(targetFolder.getStorageManager(), newFilePath); + + context.getContentResolver().notifyChange(toNotifyUri(targetFolder), null, false); + + return newFile.getDocumentId(); + } + + @Override + public void removeDocument(String documentId, String parentDocumentId) throws FileNotFoundException { + deleteDocument(documentId); + } + + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + Log_OC.d(TAG, "deleteDocument(), id=" + documentId); + + Context context = getNonNullContext(); + + Document document = toDocument(documentId); + if (!document.getFile().canDeleteOrLeaveShare()) { + showToast(R.string.document_storage_provider_cannot_delete); + return; + } + + // get parent here, because it is not available anymore after the document was deleted + Document parentFolder = document.getParent(); + + recursiveRevokePermission(document); + + OCFile file = document.getStorageManager().getFileByPath(document.getRemotePath()); + final var result = new RemoveFileOperation(file, + false, + document.getUser(), + true, + context, + document.getStorageManager()) + .execute(document.getClient()); + + if (!result.isSuccess()) { + throw new FileNotFoundException("Failed to delete document with documentId " + documentId); + } + context.getContentResolver().notifyChange(toNotifyUri(parentFolder), null, false); + } + + private void recursiveRevokePermission(Document document) { + FileDataStorageManager storageManager = document.getStorageManager(); + OCFile file = document.getFile(); + if (file.isFolder()) { + for (OCFile child : storageManager.getFolderContent(file, false)) { + recursiveRevokePermission(new Document(storageManager, child)); + } + } + + revokeDocumentPermission(document.getDocumentId()); + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + Log_OC.d(TAG, "isChildDocument(), parent=" + parentDocumentId + ", id=" + documentId); + + try { + // get file for parent document + Document parentDocument = toDocument(parentDocumentId); + OCFile parentFile = parentDocument.getFile(); + if (parentFile == null) { + throw new FileNotFoundException("No parent file with ID " + parentDocumentId); + } + // get file for child candidate document + Document currentDocument = toDocument(documentId); + OCFile childFile = currentDocument.getFile(); + if (childFile == null) { + throw new FileNotFoundException("No child file with ID " + documentId); + } + + String parentPath = parentFile.getDecryptedRemotePath(); + String childPath = childFile.getDecryptedRemotePath(); + + // The alternative is to go up the folder hierarchy from currentDocument with getParent() + // until we arrive at parentDocument or the storage root. + // However, especially for long paths this is expensive and can take substantial time. + // The solution below uses paths and is faster by a factor of 2-10 depending on the nesting level of child. + // So far, the same document with its unique ID can never be in two places at once. + // If this assumption ever changes, this code would need to be adapted. + User parentDocumentOwner = parentDocument.getUser(); + User currentDocumentOwner = currentDocument.getUser(); + return parentDocumentOwner.nameEquals(currentDocumentOwner) && childPath.startsWith(parentPath); + + } catch (FileNotFoundException e) { + Log_OC.e(TAG, "failed to check for child document", e); + } + + return false; + } + + private FileNotFoundException getFileNotFoundExceptionWithCause(String msg, Exception cause) { + FileNotFoundException e = new FileNotFoundException(msg); + e.initCause(cause); + return e; + } + + private FileDataStorageManager getStorageManager(String rootId) { + return rootIdToStorageManager.get(rootId); + } + + @VisibleForTesting + public static String rootIdForUser(User user) { + return HashUtil.md5Hash(user.getAccountName()); + } + + private void initiateStorageMap() { + + rootIdToStorageManager.clear(); + + ContentResolver contentResolver = getContext().getContentResolver(); + + for (User user : accountManager.getAllUsers()) { + final FileDataStorageManager storageManager = new FileDataStorageManager(user, contentResolver); + rootIdToStorageManager.put(rootIdForUser(user), storageManager); + } + } + + private List findFiles(Document root, String query) { + FileDataStorageManager storageManager = root.getStorageManager(); + List result = new ArrayList<>(); + for (OCFile f : storageManager.getFolderContent(root.getFile(), false)) { + if (f.isFolder()) { + result.addAll(findFiles(new Document(storageManager, f), query)); + } else if (f.getFileName().contains(query)) { + result.add(new Document(storageManager, f)); + } + } + return result; + } + + private Uri toNotifyUri(Document document) { + return DocumentsContract.buildDocumentUri( + getContext().getString(R.string.document_provider_authority), + document.getDocumentId()); + } + + private Document toDocument(String documentId) throws FileNotFoundException { + String[] separated = documentId.split(DOCUMENTID_SEPARATOR, DOCUMENTID_PARTS); + if (separated.length != DOCUMENTID_PARTS) { + throw new FileNotFoundException("Invalid documentID " + documentId + "!"); + } + + FileDataStorageManager storageManager = rootIdToStorageManager.get(separated[0]); + if (storageManager == null) { + throw new FileNotFoundException("No storage manager associated for " + documentId + "!"); + } + + return new Document(storageManager, Long.parseLong(separated[1])); + } + + /** + * Returns a {@link Context} guaranteed to be non-null. + * + * @throws IllegalStateException if called before {@link #onCreate()}. + */ + @NonNull + private Context getNonNullContext() { + Context context = getContext(); + if (context == null) { + throw new IllegalStateException(); + } + return context; + } + + public interface OnTaskFinishedCallback { + void onTaskFinished(RemoteOperationResult result); + } + + static class ReloadFolderDocumentTask extends AsyncTask { + + private final Document folder; + private final OnTaskFinishedCallback callback; + + ReloadFolderDocumentTask(Document folder, OnTaskFinishedCallback callback) { + this.folder = folder; + this.callback = callback; + } + + @Override + public final RemoteOperationResult doInBackground(Void... params) { + Log_OC.d(TAG, "run ReloadFolderDocumentTask(), id=" + folder.getDocumentId()); + return new RefreshFolderOperation(folder.getFile(), + System.currentTimeMillis(), + false, + true, + true, + folder.getStorageManager(), + folder.getUser(), + MainApp.getAppContext()) + .execute(folder.getClient()); + } + + @Override + public final void onPostExecute(RemoteOperationResult result) { + if (callback != null) { + callback.onTaskFinished(result); + } + } + } + + public class Document { + private final FileDataStorageManager storageManager; + private final long fileId; + + Document(FileDataStorageManager storageManager, long fileId) { + this.storageManager = storageManager; + this.fileId = fileId; + } + + Document(FileDataStorageManager storageManager, OCFile file) { + this.storageManager = storageManager; + this.fileId = file.getFileId(); + } + + Document(FileDataStorageManager storageManager, String filePath) { + this.storageManager = storageManager; + this.fileId = storageManager.getFileByPath(filePath).getFileId(); + } + + public String getDocumentId() { + for(String key: rootIdToStorageManager.keySet()) { + if (Objects.equals(storageManager, rootIdToStorageManager.get(key))) { + return key + DOCUMENTID_SEPARATOR + fileId; + } + } + return null; + } + + FileDataStorageManager getStorageManager() { + return storageManager; + } + + public User getUser() { + return getStorageManager().getUser(); + } + + public OCFile getFile() { + return getStorageManager().getFileById(fileId); + } + + public String getRemotePath() { + return getFile().getRemotePath(); + } + + OwnCloudClient getClient() { + try { + + OwnCloudAccount ocAccount = getUser().toOwnCloudAccount(); + return OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, getContext()); + } catch (OperationCanceledException | IOException | AuthenticatorException e) { + Log_OC.e(TAG, "Failed to set client", e); + } + return null; + } + + boolean isExpired() { + return getFile().getLastSyncDateForData() + CACHE_EXPIRATION < System.currentTimeMillis(); + } + + Document getParent() { + long parentId = getFile().getParentId(); + if (parentId <= 0) { + return null; + } + + return new Document(getStorageManager(), parentId); + } + } + + private void showToast(int messageId) { + ContextExtensionsKt.showToast(getNonNullContext(), messageId); + } + + private void showToast(String message) { + ContextExtensionsKt.showToast(getNonNullContext(), message); + } +} diff --git a/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java b/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java new file mode 100644 index 000000000000..47ecdab72fcd --- /dev/null +++ b/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java @@ -0,0 +1,730 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2013-2016 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-FileCopyrightText: 2011 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.providers; + +import android.content.ContentProvider; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Binder; +import android.text.TextUtils; + +import com.nextcloud.client.core.Clock; +import com.nextcloud.client.database.NextcloudDatabase; +import com.owncloud.android.R; +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.utils.MimeType; + +import java.util.ArrayList; +import java.util.Locale; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.sqlite.db.SimpleSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteOpenHelper; +import androidx.sqlite.db.SupportSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteQueryBuilder; +import dagger.android.AndroidInjection; +import third_parties.aosp.SQLiteTokenizer; + +/** + * The ContentProvider for the Nextcloud App. + */ +@SuppressWarnings("PMD.AvoidDuplicateLiterals") +public class FileContentProvider extends ContentProvider { + + private static final int SINGLE_FILE = 1; + private static final int DIRECTORY = 2; + private static final int ROOT_DIRECTORY = 3; + private static final int SHARES = 4; + private static final int CAPABILITIES = 5; + private static final int UPLOADS = 6; + private static final int SYNCED_FOLDERS = 7; + private static final int EXTERNAL_LINKS = 8; + private static final int VIRTUAL = 10; + private static final int FILESYSTEM = 11; + private static final String TAG = FileContentProvider.class.getSimpleName(); + // todo avoid string concatenation and use string formatting instead later. + private static final String ERROR = "ERROR "; + private static final int SINGLE_PATH_SEGMENT = 1; + public static final int MINIMUM_PATH_SEGMENTS_SIZE = 1; + + private static final String[] PROJECTION_CONTENT_TYPE = new String[]{ + ProviderTableMeta._ID, ProviderTableMeta.FILE_CONTENT_TYPE + }; + private static final String[] PROJECTION_REMOTE_ID = new String[]{ + ProviderTableMeta._ID, ProviderTableMeta.FILE_REMOTE_ID + }; + private static final String[] PROJECTION_FILE_PATH_AND_OWNER = new String[]{ + ProviderTableMeta._ID, ProviderTableMeta.FILE_PATH, ProviderTableMeta.FILE_ACCOUNT_OWNER + }; + + + @Inject protected Clock clock; + @Inject NextcloudDatabase database; + private SupportSQLiteOpenHelper mDbHelper; + private Context mContext; + private UriMatcher mUriMatcher; + + @Override + public int delete(@NonNull Uri uri, String where, String[] whereArgs) { + if (isCallerNotAllowed(uri)) { + return -1; + } + + int count; + SupportSQLiteDatabase db = mDbHelper.getWritableDatabase(); + db.beginTransaction(); + try { + count = delete(db, uri, where, whereArgs); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + mContext.getContentResolver().notifyChange(uri, null); + return count; + } + + private int delete(SupportSQLiteDatabase db, Uri uri, String where, String... whereArgs) { + if (isCallerNotAllowed(uri)) { + return -1; + } + + // verify where for public paths + switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY: + case SINGLE_FILE: + case DIRECTORY: + VerificationUtils.verifyWhere(where); + } + + return switch (mUriMatcher.match(uri)) { + case SINGLE_FILE -> deleteSingleFile(db, uri, where, whereArgs); + case DIRECTORY -> deleteDirectory(db, uri, where, whereArgs); + case ROOT_DIRECTORY -> db.delete(ProviderTableMeta.FILE_TABLE_NAME, where, whereArgs); + case SHARES -> db.delete(ProviderTableMeta.OCSHARES_TABLE_NAME, where, whereArgs); + case CAPABILITIES -> db.delete(ProviderTableMeta.CAPABILITIES_TABLE_NAME, where, whereArgs); + case UPLOADS -> db.delete(ProviderTableMeta.UPLOADS_TABLE_NAME, where, whereArgs); + case SYNCED_FOLDERS -> db.delete(ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME, where, whereArgs); + case EXTERNAL_LINKS -> db.delete(ProviderTableMeta.EXTERNAL_LINKS_TABLE_NAME, where, whereArgs); + case VIRTUAL -> db.delete(ProviderTableMeta.VIRTUAL_TABLE_NAME, where, whereArgs); + case FILESYSTEM -> db.delete(ProviderTableMeta.FILESYSTEM_TABLE_NAME, where, whereArgs); + default -> throw new IllegalArgumentException(String.format(Locale.US, "Unknown uri: %s", uri.toString())); + }; + } + + private int deleteDirectory(SupportSQLiteDatabase db, Uri uri, String where, String... whereArgs) { + int count = 0; + + Cursor children = query(uri, PROJECTION_CONTENT_TYPE, null, null, null); + if (children != null) { + if (children.moveToFirst()) { + long childId; + boolean isDir; + while (!children.isAfterLast()) { + childId = children.getLong(children.getColumnIndexOrThrow(ProviderTableMeta._ID)); + isDir = MimeType.DIRECTORY.equals(children.getString( + children.getColumnIndexOrThrow(ProviderTableMeta.FILE_CONTENT_TYPE) + )); + if (isDir) { + count += delete(db, ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, childId), + null, (String[]) null); + } else { + count += delete(db, ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_FILE, childId), + null, (String[]) null); + } + children.moveToNext(); + } + } + children.close(); + } + + if (uri.getPathSegments().size() > MINIMUM_PATH_SEGMENTS_SIZE) { + count += deleteWithUri(db, uri, where, whereArgs); + } + + return count; + } + + private int deleteSingleFile(SupportSQLiteDatabase db, Uri uri, String where, String... whereArgs) { + int count = 0; + + try (Cursor c = query(db, uri, PROJECTION_REMOTE_ID, where, whereArgs, null)) { + if (c.moveToFirst()) { + String id = c.getString(c.getColumnIndexOrThrow(ProviderTableMeta._ID)); + Log_OC.d(TAG, "Removing FILE " + id); + } + + count = deleteWithUri(db, uri, where, whereArgs); + } catch (Exception e) { + Log_OC.d(TAG, "DB-Error removing file!", e); + } + + return count; + } + + private int deleteWithUri(SupportSQLiteDatabase db, Uri uri, String where, String[] whereArgs) { + final String[] argsWithUri = VerificationUtils.prependUriFirstSegmentToSelectionArgs(whereArgs, uri); + return db.delete(ProviderTableMeta.FILE_TABLE_NAME, + ProviderTableMeta._ID + "=?" + + (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""), argsWithUri); + } + + @Override + public String getType(@NonNull Uri uri) { + return switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY -> ProviderTableMeta.CONTENT_TYPE; + case SINGLE_FILE -> ProviderTableMeta.CONTENT_TYPE_ITEM; + default -> throw new IllegalArgumentException(String.format(Locale.US, "Unknown Uri id: %s", uri)); + }; + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + if (isCallerNotAllowed(uri)) { + return null; + } + + Uri newUri; + SupportSQLiteDatabase db = mDbHelper.getWritableDatabase(); + db.beginTransaction(); + try { + newUri = insert(db, uri, values); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + mContext.getContentResolver().notifyChange(newUri, null); + return newUri; + } + + private Uri insert(SupportSQLiteDatabase db, Uri uri, ContentValues values) { + // verify only for those requests that are not internal (files table) + switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY: + case SINGLE_FILE: + case DIRECTORY: + VerificationUtils.verifyColumns(values); + break; + } + + + switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY: + case SINGLE_FILE: + return upsertSingleFile(db, uri, values); + case SHARES: + Uri insertedShareUri; + long idShares = db.insert(ProviderTableMeta.OCSHARES_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values); + if (idShares > 0) { + insertedShareUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_SHARE, idShares); + } else { + throw new SQLException(ERROR + uri); + + } + updateFilesTableAccordingToShareInsertion(db, values); + + return insertedShareUri; + + case CAPABILITIES: + Uri insertedCapUri; + long idCapabilities = db.insert(ProviderTableMeta.CAPABILITIES_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values); + if (idCapabilities > 0) { + insertedCapUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_CAPABILITIES, idCapabilities); + } else { + throw new SQLException(ERROR + uri); + } + return insertedCapUri; + + case UPLOADS: + Uri insertedUploadUri; + long uploadId = db.insert(ProviderTableMeta.UPLOADS_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values); + if (uploadId > 0) { + insertedUploadUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_UPLOADS, uploadId); + } else { + throw new SQLException(ERROR + uri); + } + return insertedUploadUri; + + case SYNCED_FOLDERS: + Uri insertedSyncedFolderUri; + long syncedFolderId = db.insert(ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values); + if (syncedFolderId > 0) { + insertedSyncedFolderUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + syncedFolderId); + } else { + throw new SQLException("ERROR " + uri); + } + return insertedSyncedFolderUri; + + case EXTERNAL_LINKS: + Uri insertedExternalLinkUri; + long externalLinkId = db.insert(ProviderTableMeta.EXTERNAL_LINKS_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values); + if (externalLinkId > 0) { + insertedExternalLinkUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_EXTERNAL_LINKS, + externalLinkId); + } else { + throw new SQLException("ERROR " + uri); + } + return insertedExternalLinkUri; + + case VIRTUAL: + Uri insertedVirtualUri; + long virtualId = db.insert(ProviderTableMeta.VIRTUAL_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values); + + if (virtualId > 0) { + insertedVirtualUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_VIRTUAL, virtualId); + } else { + throw new SQLException("ERROR " + uri); + } + + return insertedVirtualUri; + case FILESYSTEM: + Uri insertedFilesystemUri; + long filesystemId = db.insert(ProviderTableMeta.FILESYSTEM_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values); + if (filesystemId > 0) { + insertedFilesystemUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_FILESYSTEM, + filesystemId); + } else { + throw new SQLException("ERROR " + uri); + } + return insertedFilesystemUri; + default: + throw new IllegalArgumentException("Unknown uri id: " + uri); + } + } + + public Uri upsertSingleFile(SupportSQLiteDatabase db, Uri uri, ContentValues values) { + String filePath = values.getAsString(ProviderTableMeta.FILE_PATH); + String accountOwner = values.getAsString(ProviderTableMeta.FILE_ACCOUNT_OWNER); + + String where = ProviderTableMeta.FILE_PATH + "=? AND " + ProviderTableMeta.FILE_ACCOUNT_OWNER + "=?"; + String[] whereArgs = {filePath, accountOwner}; + + // Try insert first, ignore conflict + long rowId = db.insert( + ProviderTableMeta.FILE_TABLE_NAME, + SQLiteDatabase.CONFLICT_IGNORE, + values); + + if (rowId <= 0) { + // Already exists: update + int count = db.update( + ProviderTableMeta.FILE_TABLE_NAME, + SQLiteDatabase.CONFLICT_NONE, + values, + where, + whereArgs); + + if (count == 0) { + throw new SQLException("Failed to update existing file: " + uri); + } + + try (Cursor cursor = db.query( + new SimpleSQLiteQuery( + "SELECT " + ProviderTableMeta._ID + + " FROM " + ProviderTableMeta.FILE_TABLE_NAME + + " WHERE " + where, + whereArgs + ))) { + if (cursor.moveToFirst()) { + rowId = cursor.getLong(0); + } else { + throw new SQLException("Failed to fetch ID after update: " + uri); + } + } + } + + return ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_FILE, rowId); + } + + private void updateFilesTableAccordingToShareInsertion(SupportSQLiteDatabase db, ContentValues newShare) { + ContentValues fileValues = new ContentValues(); + Integer shareTypeValue = newShare.getAsInteger(ProviderTableMeta.OCSHARES_SHARE_TYPE); + if (shareTypeValue == null) { + Log_OC.w(TAG, "Share type is null. Skipping file update."); + return; + } + + ShareType newShareType = ShareType.fromValue(shareTypeValue); + + switch (newShareType) { + case PUBLIC_LINK: + fileValues.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, 1); + break; + + case USER: + case GROUP: + case EMAIL: + case FEDERATED: + case FEDERATED_GROUP: + case ROOM: + case CIRCLE: + case DECK: + case GUEST: + fileValues.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, 1); + break; + + default: + // everything should be handled + } + + String where = ProviderTableMeta.FILE_PATH + "=? AND " + ProviderTableMeta.FILE_ACCOUNT_OWNER + "=?"; + String[] whereArgs = new String[]{ + newShare.getAsString(ProviderTableMeta.OCSHARES_PATH), + newShare.getAsString(ProviderTableMeta.OCSHARES_ACCOUNT_OWNER) + }; + db.update(ProviderTableMeta.FILE_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, fileValues, where, whereArgs); + } + + + @Override + public boolean onCreate() { + AndroidInjection.inject(this); + mDbHelper = database.getOpenHelper(); + mContext = getContext(); + + if (mContext == null) { + return false; + } + + String authority = mContext.getResources().getString(R.string.authority); + mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + mUriMatcher.addURI(authority, null, ROOT_DIRECTORY); + mUriMatcher.addURI(authority, "file/", SINGLE_FILE); + mUriMatcher.addURI(authority, "file/#", SINGLE_FILE); + mUriMatcher.addURI(authority, "dir/", DIRECTORY); + mUriMatcher.addURI(authority, "dir/#", DIRECTORY); + mUriMatcher.addURI(authority, "shares/", SHARES); + mUriMatcher.addURI(authority, "shares/#", SHARES); + mUriMatcher.addURI(authority, "capabilities/", CAPABILITIES); + mUriMatcher.addURI(authority, "capabilities/#", CAPABILITIES); + mUriMatcher.addURI(authority, "uploads/", UPLOADS); + mUriMatcher.addURI(authority, "uploads/#", UPLOADS); + mUriMatcher.addURI(authority, "synced_folders", SYNCED_FOLDERS); + mUriMatcher.addURI(authority, "external_links", EXTERNAL_LINKS); + mUriMatcher.addURI(authority, "virtual", VIRTUAL); + mUriMatcher.addURI(authority, "filesystem", FILESYSTEM); + + return true; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + + // skip check for files as they need to be queried to get access via document provider + switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY: + case SINGLE_FILE: + case DIRECTORY: + break; + + default: + if (isCallerNotAllowed(uri)) { + return null; + } + } + + Cursor result; + SupportSQLiteDatabase db = mDbHelper.getReadableDatabase(); + db.beginTransaction(); + try { + result = query(db, uri, projection, selection, selectionArgs, sortOrder); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + return result; + } + + private Cursor query(SupportSQLiteDatabase db, + Uri uri, + String[] projectionArray, + String selection, + String[] selectionArgs, + String sortOrder) { + + // verify only for those requests that are not internal + final int uriMatch = mUriMatcher.match(uri); + + String tableName = switch (uriMatch) { + case ROOT_DIRECTORY, DIRECTORY, SINGLE_FILE -> { + VerificationUtils.verifyWhere(selection); // prevent injection in public paths + yield ProviderTableMeta.FILE_TABLE_NAME; + } + case SHARES -> ProviderTableMeta.OCSHARES_TABLE_NAME; + case CAPABILITIES -> ProviderTableMeta.CAPABILITIES_TABLE_NAME; + case UPLOADS -> ProviderTableMeta.UPLOADS_TABLE_NAME; + case SYNCED_FOLDERS -> ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME; + case EXTERNAL_LINKS -> ProviderTableMeta.EXTERNAL_LINKS_TABLE_NAME; + case VIRTUAL -> ProviderTableMeta.VIRTUAL_TABLE_NAME; + case FILESYSTEM -> ProviderTableMeta.FILESYSTEM_TABLE_NAME; + default -> throw new IllegalArgumentException("Unknown uri id: " + uri); + }; + + SupportSQLiteQueryBuilder queryBuilder = SupportSQLiteQueryBuilder.builder(tableName); + + + // add ID to arguments if Uri has more than one segment + if (uriMatch != ROOT_DIRECTORY && uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { + String idColumn = uriMatch == DIRECTORY ? ProviderTableMeta.FILE_PARENT : ProviderTableMeta._ID; + selection = idColumn + "=? AND " + selection; + selectionArgs = VerificationUtils.prependUriFirstSegmentToSelectionArgs(selectionArgs, uri); + } + + + String order; + if (TextUtils.isEmpty(sortOrder)) { + order = switch (uriMatch) { + case SHARES -> ProviderTableMeta.OCSHARES_DEFAULT_SORT_ORDER; + case CAPABILITIES -> ProviderTableMeta.CAPABILITIES_DEFAULT_SORT_ORDER; + case UPLOADS -> ProviderTableMeta.UPLOADS_DEFAULT_SORT_ORDER; + case SYNCED_FOLDERS -> ProviderTableMeta.SYNCED_FOLDER_LOCAL_PATH; + case EXTERNAL_LINKS -> ProviderTableMeta.EXTERNAL_LINKS_NAME; + case VIRTUAL -> ProviderTableMeta.VIRTUAL_TYPE; + case FILESYSTEM -> ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH; + default -> // Files + ProviderTableMeta.FILE_DEFAULT_SORT_ORDER; + }; + } else { + if (uriMatch == ROOT_DIRECTORY || uriMatch == SINGLE_FILE || uriMatch == DIRECTORY) { + VerificationUtils.verifySortOrder(sortOrder); + } + order = sortOrder; + } + + // DB case_sensitive + db.execSQL("PRAGMA case_sensitive_like = true"); + + // only file list is publicly accessible via content provider, so only this has to be protected + if ((uriMatch == ROOT_DIRECTORY || uriMatch == SINGLE_FILE || + uriMatch == DIRECTORY) && projectionArray != null && projectionArray.length > 0) { + for (String column : projectionArray) { + VerificationUtils.verifyColumnName(column); + } + } + + // if both are null, let them pass to query + if (selectionArgs == null && selection != null) { + selectionArgs = new String[]{selection}; + selection = "(?)"; + } + + if (!TextUtils.isEmpty(selection)) { + queryBuilder.selection(selection, selectionArgs); + } + if (!TextUtils.isEmpty(order)) { + queryBuilder.orderBy(order); + } + if (projectionArray != null && projectionArray.length > 0) { + queryBuilder.columns(projectionArray); + } + final SupportSQLiteQuery supportSQLiteQuery = queryBuilder.create(); + final Cursor c = db.query(supportSQLiteQuery); + c.setNotificationUri(mContext.getContentResolver(), uri); + return c; + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { + if (isCallerNotAllowed(uri)) { + return -1; + } + + int count; + SupportSQLiteDatabase db = mDbHelper.getWritableDatabase(); + db.beginTransaction(); + try { + count = update(db, uri, values, selection, selectionArgs); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + mContext.getContentResolver().notifyChange(uri, null); + return count; + } + + private int update(SupportSQLiteDatabase db, Uri uri, ContentValues values, String selection, String... selectionArgs) { + // verify contentValues and selection for public paths to prevent injection + switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY: + case SINGLE_FILE: + case DIRECTORY: + VerificationUtils.verifyColumns(values); + VerificationUtils.verifyWhere(selection); + } + + return switch (mUriMatcher.match(uri)) { + case DIRECTORY -> 0; + case SHARES -> + db.update(ProviderTableMeta.OCSHARES_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values, selection, selectionArgs); + case CAPABILITIES -> + db.update(ProviderTableMeta.CAPABILITIES_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values, selection, selectionArgs); + case UPLOADS -> + db.update(ProviderTableMeta.UPLOADS_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values, selection, selectionArgs); + case SYNCED_FOLDERS -> + db.update(ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values, selection, selectionArgs); + case FILESYSTEM -> + db.update(ProviderTableMeta.FILESYSTEM_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values, selection, selectionArgs); + default -> + db.update(ProviderTableMeta.FILE_TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values, selection, selectionArgs); + }; + } + + @NonNull + @Override + public ContentProviderResult[] applyBatch(@NonNull ArrayList operations) + throws OperationApplicationException { + Log_OC.d("FileContentProvider", "applying batch in provider " + this + + " (temporary: " + isTemporary() + ")"); + ContentProviderResult[] results = new ContentProviderResult[operations.size()]; + int i = 0; + + SupportSQLiteDatabase database = mDbHelper.getWritableDatabase(); + database.beginTransaction(); // it's supposed that transactions can be nested + try { + for (ContentProviderOperation operation : operations) { + results[i] = operation.apply(this, results, i); + i++; + } + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + Log_OC.d("FileContentProvider", "applied batch in provider " + this); + return results; + } + + private boolean isCallerNotAllowed(Uri uri) { + return switch (mUriMatcher.match(uri)) { + case SHARES, CAPABILITIES, UPLOADS, SYNCED_FOLDERS, EXTERNAL_LINKS, VIRTUAL, FILESYSTEM -> { + String callingPackage = mContext.getPackageManager().getNameForUid(Binder.getCallingUid()); + yield callingPackage == null || !callingPackage.equals(mContext.getPackageName()); + } + default -> false; + }; + } + + + static class VerificationUtils { + + private static boolean isValidColumnName(@NonNull String columnName) { + return ProviderTableMeta.FILE_ALL_COLUMNS.contains(columnName); + } + + @VisibleForTesting + public static void verifyColumns(@Nullable ContentValues contentValues) { + if (contentValues == null || contentValues.keySet().isEmpty()) { + return; + } + + for (String name : contentValues.keySet()) { + verifyColumnName(name); + } + } + + public static void verifyColumnName(@NonNull String columnName) { + if (!isValidColumnName(columnName)) { + throw new IllegalArgumentException(String.format("Column name \"%s\" is not allowed", columnName)); + } + } + + public static String[] prependUriFirstSegmentToSelectionArgs(@Nullable final String[] originalArgs, final Uri uri) { + String[] args; + if (originalArgs == null) { + args = new String[1]; + } else { + args = new String[originalArgs.length + 1]; + System.arraycopy(originalArgs, 0, args, 1, originalArgs.length); + } + args[0] = uri.getPathSegments().get(1); + return args; + } + + public static void verifySortOrder(@Nullable String sortOrder) { + if (sortOrder == null) { + return; + } + SQLiteTokenizer.tokenize(sortOrder, SQLiteTokenizer.OPTION_NONE, VerificationUtils::verifySortToken); + } + + private static void verifySortToken(String token){ + // accept empty tokens and valid column names + if (TextUtils.isEmpty(token) || isValidColumnName(token)) { + return; + } + // accept only a small subset of keywords + if(SQLiteTokenizer.isKeyword(token)){ + switch (token.toUpperCase(Locale.ROOT)) { + case "ASC": + case "DESC": + case "COLLATE": + case "NOCASE": + return; + } + } + // if none of the above, invalid token + throw new IllegalArgumentException("Invalid token " + token); + } + + public static void verifyWhere(@Nullable String where) { + if (where == null) { + return; + } + SQLiteTokenizer.tokenize(where, SQLiteTokenizer.OPTION_NONE, VerificationUtils::verifyWhereToken); + } + + private static void verifyWhereToken(String token) { + // allow empty, valid column names, functions (min,max,count) and types + if (TextUtils.isEmpty(token) || isValidColumnName(token) + || SQLiteTokenizer.isFunction(token) || SQLiteTokenizer.isType(token)) { + return; + } + + // Disallow dangerous keywords, allow others + if (SQLiteTokenizer.isKeyword(token)) { + switch (token.toUpperCase(Locale.ROOT)) { + case "SELECT": + case "FROM": + case "WHERE": + case "GROUP": + case "HAVING": + case "WINDOW": + case "VALUES": + case "ORDER": + case "LIMIT": + throw new IllegalArgumentException("Invalid token " + token); + default: + return; + } + } + + // if none of the above: invalid token + throw new IllegalArgumentException("Invalid token " + token); + } + } +} diff --git a/app/src/main/java/com/owncloud/android/providers/UsersAndGroupsSearchConfig.kt b/app/src/main/java/com/owncloud/android/providers/UsersAndGroupsSearchConfig.kt new file mode 100644 index 000000000000..dc38546434e1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/providers/UsersAndGroupsSearchConfig.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Álvaro Brey + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.providers + +/** + * This is a data class that holds the configuration for the user and group searchable. + * As we cannot access searchable providers in runtime, injecting a singleton into them is the only way to change their + * config. + */ +data class UsersAndGroupsSearchConfig(var searchOnlyUsers: Boolean = false) { + fun reset() { + searchOnlyUsers = false + } +} diff --git a/app/src/main/java/com/owncloud/android/providers/UsersAndGroupsSearchProvider.java b/app/src/main/java/com/owncloud/android/providers/UsersAndGroupsSearchProvider.java new file mode 100644 index 000000000000..ef8df0f9c355 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/providers/UsersAndGroupsSearchProvider.java @@ -0,0 +1,483 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019-2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2016 Juan Carlos González Cabrero + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.providers; + +import android.app.SearchManager; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.provider.BaseColumns; +import android.text.TextUtils; +import android.util.Log; +import android.widget.Toast; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.lib.resources.users.Status; +import com.owncloud.android.lib.resources.users.StatusType; +import com.owncloud.android.ui.TextDrawable; +import com.owncloud.android.utils.BitmapUtils; +import com.owncloud.android.utils.ErrorMessageAdapter; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import dagger.android.AndroidInjection; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import static com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation.PROPERTY_CLEAR_AT; +import static com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation.PROPERTY_ICON; +import static com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation.PROPERTY_MESSAGE; +import static com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation.PROPERTY_STATUS; + +/** + * Content provider for search suggestions, to search for users and groups existing in an ownCloud server. + */ +public class UsersAndGroupsSearchProvider extends ContentProvider { + + private static final String TAG = UsersAndGroupsSearchProvider.class.getSimpleName(); + + private static final String[] COLUMNS = { + BaseColumns._ID, + SearchManager.SUGGEST_COLUMN_TEXT_1, + SearchManager.SUGGEST_COLUMN_TEXT_2, + SearchManager.SUGGEST_COLUMN_ICON_1, + SearchManager.SUGGEST_COLUMN_INTENT_DATA + }; + + private static final int SEARCH = 1; + + private static final int RESULTS_PER_PAGE = 50; + private static final int REQUESTED_PAGE = 1; + + @SuppressFBWarnings("MS_CANNOT_BE_FINAL") + public static String ACTION_SHARE_WITH; + + public static final String CONTENT = "content"; + + private String AUTHORITY; + private String DATA_USER; + private String DATA_GROUP; + private String DATA_ROOM; + private String DATA_REMOTE; + private String DATA_REMOTE_GROUP; + private String DATA_EMAIL; + private String DATA_CIRCLE; + + private UriMatcher mUriMatcher; + + @Inject + protected UserAccountManager accountManager; + @Inject + protected UsersAndGroupsSearchConfig searchConfig; + + private static final Map sShareTypes = new HashMap<>(); + + public static ShareType getShareType(String authority) { + + return sShareTypes.get(authority); + } + + private static void setActionShareWith(@NonNull Context context) { + ACTION_SHARE_WITH = context.getString(R.string.users_and_groups_share_with); + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + // TODO implement + return null; + } + + @Override + public boolean onCreate() { + AndroidInjection.inject(this); + + if (getContext() == null) { + return false; + } + + AUTHORITY = getContext().getString(R.string.users_and_groups_search_authority); + setActionShareWith(getContext()); + DATA_USER = AUTHORITY + ".data.user"; + DATA_GROUP = AUTHORITY + ".data.group"; + DATA_ROOM = AUTHORITY + ".data.room"; + DATA_REMOTE = AUTHORITY + ".data.remote"; + DATA_REMOTE_GROUP = AUTHORITY + ".data.remote_group"; + DATA_EMAIL = AUTHORITY + ".data.email"; + DATA_CIRCLE = AUTHORITY + ".data.circle"; + + sShareTypes.put(DATA_USER, ShareType.USER); + sShareTypes.put(DATA_GROUP, ShareType.GROUP); + sShareTypes.put(DATA_ROOM, ShareType.ROOM); + sShareTypes.put(DATA_REMOTE, ShareType.FEDERATED); + sShareTypes.put(DATA_REMOTE_GROUP, ShareType.FEDERATED_GROUP); + sShareTypes.put(DATA_EMAIL, ShareType.EMAIL); + sShareTypes.put(DATA_CIRCLE, ShareType.CIRCLE); + + mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + mUriMatcher.addURI(AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY + "/*", SEARCH); + + return true; + } + + /** + * returns sharee from server + * + * Reference: http://developer.android.com/guide/topics/search/adding-custom-suggestions.html#CustomContentProvider + * + * @param uri Content {@link Uri}, formatted as "content://com.nextcloud.android.providers.UsersAndGroupsSearchProvider/" + * + {@link android.app.SearchManager#SUGGEST_URI_PATH_QUERY} + "/" + + * 'userQuery' + * @param projection Expected to be NULL. + * @param selection Expected to be NULL. + * @param selectionArgs Expected to be NULL. + * @param sortOrder Expected to be NULL. + * @return Cursor with possible sharees in the server that match 'query'. + */ + @Nullable + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + Log_OC.d(TAG, "query received in thread " + Thread.currentThread().getName()); + + int match = mUriMatcher.match(uri); + if (match == SEARCH) { + return searchForUsersOrGroups(uri); + } + return null; + } + + private Cursor searchForUsersOrGroups(Uri uri) { + + // TODO check searchConfig and filter results + Log.d(TAG, "searchForUsersOrGroups: searchConfig only users: " + searchConfig.getSearchOnlyUsers()); + + String lastPathSegment = uri.getLastPathSegment(); + + if (lastPathSegment == null) { + throw new IllegalArgumentException("Wrong URI passed!"); + } + + // need to trust on the AccountUtils to get the current account since the query in the client side is not + // directly started by our code, but from SearchView implementation + User user = accountManager.getUser(); + + String userQuery = lastPathSegment.toLowerCase(Locale.ROOT); + + // request to the OC server about users and groups matching userQuery + GetShareesRemoteOperation searchRequest = new GetShareesRemoteOperation(userQuery, + REQUESTED_PAGE, + RESULTS_PER_PAGE); + RemoteOperationResult> result = searchRequest.execute(user, getContext()); + List names = new ArrayList<>(); + + if (result.isSuccess()) { + names = result.getResultData(); + } else { + showErrorMessage(result); + } + + MatrixCursor response = null; + // convert the responses from the OC server to the expected format + if (names.size() > 0) { + if (getContext() == null) { + throw new IllegalArgumentException("Context may not be null!"); + } + + response = new MatrixCursor(COLUMNS); + + Uri userBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_USER).build(); + Uri groupBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_GROUP).build(); + Uri roomBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_ROOM).build(); + Uri remoteBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_REMOTE).build(); + Uri remoteGroupBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_REMOTE_GROUP).build(); + Uri emailBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_EMAIL).build(); + Uri circleBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_CIRCLE).build(); + + FileDataStorageManager manager = new FileDataStorageManager(user, + getContext().getContentResolver()); + boolean federatedShareAllowed = manager.getCapability(user.getAccountName()) + .getFilesSharingFederationOutgoing() + .isTrue(); + + try { + Iterator namesIt = names.iterator(); + JSONObject item; + String displayName; + String subline = null; + Object icon = 0; + Uri dataUri; + int count = 0; + while (namesIt.hasNext()) { + item = namesIt.next(); + dataUri = null; + displayName = null; + String userName = item.getString(GetShareesRemoteOperation.PROPERTY_LABEL); + String name = item.isNull("name") ? "" : item.getString("name"); + JSONObject value = item.getJSONObject(GetShareesRemoteOperation.NODE_VALUE); + ShareType type = ShareType.fromValue(value.getInt(GetShareesRemoteOperation.PROPERTY_SHARE_TYPE)); + String shareWith = value.getString(GetShareesRemoteOperation.PROPERTY_SHARE_WITH); + + Status status; + JSONObject statusObject = item.optJSONObject(PROPERTY_STATUS); + + if (statusObject != null) { + status = new Status( + StatusType.valueOf(statusObject.getString(PROPERTY_STATUS).toUpperCase(Locale.US)), + statusObject.isNull(PROPERTY_MESSAGE) ? "" : statusObject.getString(PROPERTY_MESSAGE), + statusObject.isNull(PROPERTY_ICON) ? "" : statusObject.getString(PROPERTY_ICON), + statusObject.isNull(PROPERTY_CLEAR_AT) ? -1 : statusObject.getLong(PROPERTY_CLEAR_AT)); + } else { + status = new Status(StatusType.OFFLINE, "", "", -1); + } + + if (searchConfig.getSearchOnlyUsers() && type != ShareType.USER) { + // skip all types but users, as E2E secure share is only allowed to users on same server + continue; + } + + switch (type) { + case GROUP: + displayName = userName; + icon = R.drawable.ic_group; + dataUri = Uri.withAppendedPath(groupBaseUri, shareWith); + break; + + case FEDERATED: + if (federatedShareAllowed) { + icon = R.drawable.ic_user_outline; + dataUri = Uri.withAppendedPath(remoteBaseUri, shareWith); + + if (userName.equals(shareWith)) { + displayName = name; + subline = getContext().getString(R.string.remote); + } else { + String[] uriSplitted = shareWith.split("@"); + displayName = name; + subline = getContext().getString(R.string.share_known_remote_on_clarification, + uriSplitted[uriSplitted.length - 1]); + } + } + break; + + case FEDERATED_GROUP: + if (federatedShareAllowed) { + icon = R.drawable.ic_group; + dataUri = Uri.withAppendedPath(remoteGroupBaseUri, shareWith); + + if (userName.equals(shareWith)) { + displayName = name; + subline = getContext().getString(R.string.remote); + subline = ""; + } else { + String[] uriSplitted = shareWith.split("@"); + displayName = name; + subline = getContext().getString(R.string.share_known_remote_on_clarification, + uriSplitted[uriSplitted.length - 1]); + } + } + break; + + case USER: + displayName = userName; + subline = (status.getMessage() == null || status.getMessage().isEmpty()) ? null : + status.getMessage(); + Uri.Builder builder = Uri.parse("content://" + AUTHORITY + "/icon").buildUpon(); + + builder.appendQueryParameter("shareWith", shareWith); + builder.appendQueryParameter("displayName", displayName); + builder.appendQueryParameter("status", status.getStatus().toString()); + + if (!TextUtils.isEmpty(status.getIcon()) && !"null".equals(status.getIcon())) { + builder.appendQueryParameter("icon", status.getIcon()); + } + + icon = builder.build(); + + dataUri = Uri.withAppendedPath(userBaseUri, shareWith); + break; + + case EMAIL: + icon = R.drawable.ic_email; + displayName = name; + subline = shareWith; + dataUri = Uri.withAppendedPath(emailBaseUri, shareWith); + break; + + case ROOM: + icon = R.drawable.ic_talk; + displayName = userName; + dataUri = Uri.withAppendedPath(roomBaseUri, shareWith); + break; + + case CIRCLE: + icon = R.drawable.ic_circles; + displayName = userName; + dataUri = Uri.withAppendedPath(circleBaseUri, shareWith); + break; + + default: + break; + } + + if (displayName != null && dataUri != null) { + response.newRow() + .add(count++) // BaseColumns._ID + .add(displayName) // SearchManager.SUGGEST_COLUMN_TEXT_1 + .add(subline) // SearchManager.SUGGEST_COLUMN_TEXT_2 + .add(icon) // SearchManager.SUGGEST_COLUMN_ICON_1 + .add(dataUri); + } + } + + } catch (JSONException e) { + Log_OC.e(TAG, "Exception while parsing data of users/groups", e); + } + } + + return response; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } + + @Nullable + @Override + @SuppressFBWarnings("IOI_USE_OF_FILE_STREAM_CONSTRUCTORS") // TODO remove with API26 + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(getContext()); + + String userId = uri.getQueryParameter("shareWith"); + String displayName = uri.getQueryParameter("displayName"); + String accountName = accountManager.getUser().getAccountName(); + String serverName = accountName.substring(accountName.lastIndexOf('@') + 1); + + String eTag = arbitraryDataProvider.getValue(userId + "@" + serverName, ThumbnailsCacheManager.AVATAR); + String avatarKey = "a_" + userId + "_" + serverName + "_" + eTag; + + StatusType status = StatusType.valueOf(uri.getQueryParameter("status")); + String icon = uri.getQueryParameter("icon"); + + if (icon == null) { + icon = ""; + } + + Bitmap avatarBitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(avatarKey); + + if (avatarBitmap == null) { + float avatarRadius = getContext().getResources().getDimension(R.dimen.list_item_avatar_icon_radius); + avatarBitmap = BitmapUtils.drawableToBitmap(TextDrawable.createNamedAvatar(displayName, avatarRadius)); + } + + Bitmap avatar = BitmapUtils.createAvatarWithStatus(avatarBitmap, status, icon, getContext()); + + // create a file to write bitmap data + File f = new File(getContext().getCacheDir(), "test"); + try { + if (f.exists()) { + if (!f.delete()) { + throw new IllegalStateException("Existing file could not be deleted!"); + } + } + if (!f.createNewFile()) { + throw new IllegalStateException("File could not be created!"); + } + + //Convert bitmap to byte array + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + + avatar.compress(Bitmap.CompressFormat.PNG, 90, bos); + byte[] bitmapData = bos.toByteArray(); + + //write the bytes in file + try (FileOutputStream fos = new FileOutputStream(f)) { + fos.write(bitmapData); + } catch (FileNotFoundException e) { + Log_OC.e(TAG, "File not found: " + e.getMessage()); + } + + } catch (Exception e) { + Log_OC.e(TAG, "Error opening file: " + e.getMessage()); + } + + return ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY); + } + + /** + * Show error message + * + * @param result Result with the failure information. + */ + private void showErrorMessage(final RemoteOperationResult result) { + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(() -> { + // The Toast must be shown in the main thread to grant that will be hidden correctly; otherwise + // the thread may die before, an exception will occur, and the message will be left on the screen + // until the app dies + + Context context = getContext(); + + if (context == null) { + throw new IllegalArgumentException("Context may not be null!"); + } + + Toast.makeText(getContext().getApplicationContext(), + ErrorMessageAdapter.getErrorCauseMessage(result, null, getContext().getResources()), + Toast.LENGTH_SHORT).show(); + }); + } +} diff --git a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java new file mode 100644 index 000000000000..05d7b54dc0c5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.services; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import com.nextcloud.android.sso.InputStreamBinder; +import com.nextcloud.client.account.UserAccountManager; + +import javax.inject.Inject; + +import dagger.android.AndroidInjection; + +public class AccountManagerService extends Service { + + private InputStreamBinder mBinder; + @Inject UserAccountManager accountManager; + + @Override + public void onCreate() { + super.onCreate(); + AndroidInjection.inject(this); + } + + @Override + public IBinder onBind(Intent intent) { + if(mBinder == null) { + mBinder = new InputStreamBinder(getApplicationContext(), accountManager); + } + return mBinder; + } + + @Override + public boolean onUnbind(Intent intent) { + return super.onUnbind(intent); + } + +} diff --git a/app/src/main/java/com/owncloud/android/services/OperationsService.java b/app/src/main/java/com/owncloud/android/services/OperationsService.java new file mode 100644 index 000000000000..d8a645f6d98e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/services/OperationsService.java @@ -0,0 +1,842 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 TSI-mc + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.services; + +import android.accounts.Account; +import android.accounts.AccountsException; +import android.app.Service; +import android.content.Intent; +import android.net.Uri; +import android.os.Binder; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.Process; +import android.text.TextUtils; +import android.util.Pair; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.owncloud.android.MainApp; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.RestoreFileVersionRemoteOperation; +import com.owncloud.android.lib.resources.files.model.FileVersion; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.lib.resources.users.GetUserInfoRemoteOperation; +import com.owncloud.android.operations.CheckCurrentCredentialsOperation; +import com.owncloud.android.operations.CopyFileOperation; +import com.owncloud.android.operations.CreateFolderOperation; +import com.owncloud.android.operations.CreateShareViaLinkOperation; +import com.owncloud.android.operations.CreateShareWithShareeOperation; +import com.owncloud.android.operations.GetServerInfoOperation; +import com.owncloud.android.operations.MoveFileOperation; +import com.owncloud.android.operations.RemoveFileOperation; +import com.owncloud.android.operations.RenameFileOperation; +import com.owncloud.android.operations.SetFilesDownloadLimitOperation; +import com.owncloud.android.operations.SynchronizeFileOperation; +import com.owncloud.android.operations.SynchronizeFolderOperation; +import com.owncloud.android.operations.UnshareOperation; +import com.owncloud.android.operations.UpdateNoteForShareOperation; +import com.owncloud.android.operations.UpdateShareInfoOperation; +import com.owncloud.android.operations.UpdateSharePermissionsOperation; +import com.owncloud.android.operations.UpdateShareViaLinkOperation; + +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentMap; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import dagger.android.AndroidInjection; + +public class OperationsService extends Service { + + private static final String TAG = OperationsService.class.getSimpleName(); + + public static final String EXTRA_ENCRYPTED = "ENCRYPTED"; + public static final String EXTRA_ACCOUNT = "ACCOUNT"; + public static final String EXTRA_POST_DIALOG_EVENT = "EXTRA_POST_DIALOG_EVENT"; + public static final String EXTRA_SERVER_URL = "SERVER_URL"; + public static final String EXTRA_REMOTE_PATH = "REMOTE_PATH"; + public static final String EXTRA_NEWNAME = "NEWNAME"; + public static final String EXTRA_REMOVE_ONLY_LOCAL = "REMOVE_LOCAL_COPY"; + public static final String EXTRA_SYNC_FILE_CONTENTS = "SYNC_FILE_CONTENTS"; + public static final String EXTRA_NEW_PARENT_PATH = "NEW_PARENT_PATH"; + public static final String EXTRA_FILE = "FILE"; + public static final String EXTRA_FILE_VERSION = "FILE_VERSION"; + public static final String EXTRA_SHARE_PASSWORD = "SHARE_PASSWORD"; + public static final String EXTRA_SHARE_TYPE = "SHARE_TYPE"; + public static final String EXTRA_SHARE_WITH = "SHARE_WITH"; + public static final String EXTRA_SHARE_EXPIRATION_DATE_IN_MILLIS = "SHARE_EXPIRATION_YEAR"; + public static final String EXTRA_SHARE_PERMISSIONS = "SHARE_PERMISSIONS"; + public static final String EXTRA_SHARE_PUBLIC_LABEL = "SHARE_PUBLIC_LABEL"; + public static final String EXTRA_SHARE_HIDE_FILE_DOWNLOAD = "HIDE_FILE_DOWNLOAD"; + public static final String EXTRA_SHARE_ID = "SHARE_ID"; + public static final String EXTRA_SHARE_REMOTE_ID = "SHARE_REMOTE_ID"; + public static final String EXTRA_SHARE_NOTE = "SHARE_NOTE"; + public static final String EXTRA_IN_BACKGROUND = "IN_BACKGROUND"; + public static final String EXTRA_FILES_DOWNLOAD_LIMIT = "FILES_DOWNLOAD_LIMIT"; + public static final String EXTRA_SHARE_ATTRIBUTES = "SHARE_ATTRIBUTES"; + + public static final String ACTION_CREATE_SHARE_VIA_LINK = "CREATE_SHARE_VIA_LINK"; + public static final String ACTION_CREATE_SECURE_FILE_DROP = "CREATE_SECURE_FILE_DROP"; + public static final String ACTION_CREATE_SHARE_WITH_SHAREE = "CREATE_SHARE_WITH_SHAREE"; + public static final String ACTION_UNSHARE = "UNSHARE"; + public static final String ACTION_UPDATE_PUBLIC_SHARE = "UPDATE_PUBLIC_SHARE"; + public static final String ACTION_UPDATE_USER_SHARE = "UPDATE_USER_SHARE"; + public static final String ACTION_UPDATE_SHARE_NOTE = "UPDATE_SHARE_NOTE"; + public static final String ACTION_UPDATE_SHARE_INFO = "UPDATE_SHARE_INFO"; + public static final String ACTION_GET_SERVER_INFO = "GET_SERVER_INFO"; + public static final String ACTION_GET_USER_NAME = "GET_USER_NAME"; + public static final String ACTION_RENAME = "RENAME"; + public static final String ACTION_REMOVE = "REMOVE"; + public static final String ACTION_CREATE_FOLDER = "CREATE_FOLDER"; + public static final String ACTION_SYNC_FILE = "SYNC_FILE"; + public static final String ACTION_SYNC_FOLDER = "SYNC_FOLDER"; + public static final String ACTION_MOVE_FILE = "MOVE_FILE"; + public static final String ACTION_COPY_FILE = "COPY_FILE"; + public static final String ACTION_CHECK_CURRENT_CREDENTIALS = "CHECK_CURRENT_CREDENTIALS"; + public static final String ACTION_RESTORE_VERSION = "RESTORE_VERSION"; + public static final String ACTION_UPDATE_FILES_DOWNLOAD_LIMIT = "UPDATE_FILES_DOWNLOAD_LIMIT"; + + private ServiceHandler mOperationsHandler; + private OperationsServiceBinder mOperationsBinder; + + private SyncFolderHandler mSyncFolderHandler; + + private ConcurrentMap> + mUndispatchedFinishedOperations = new ConcurrentHashMap<>(); + + @Inject UserAccountManager accountManager; + @Inject ArbitraryDataProvider arbitraryDataProvider; + + private static class Target { + public Uri mServerUrl; + public Account mAccount; + + public Target(Account account, Uri serverUrl) { + mAccount = account; + mServerUrl = serverUrl; + } + } + + /** + * Service initialization + */ + @Override + public void onCreate() { + super.onCreate(); + AndroidInjection.inject(this); + Log_OC.d(TAG, "Creating service"); + + // First worker thread for most of operations + HandlerThread thread = new HandlerThread("Operations thread", + Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + mOperationsHandler = new ServiceHandler(thread.getLooper(), this); + mOperationsBinder = new OperationsServiceBinder(mOperationsHandler); + + // Separated worker thread for download of folders (WIP) + thread = new HandlerThread("Syncfolder thread", Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + mSyncFolderHandler = new SyncFolderHandler(thread.getLooper(), this); + } + + /** + * Entry point to add a new operation to the queue of operations. + *

+ * New operations are added calling to startService(), resulting in a call to this method. This ensures the service + * will keep on working although the caller activity goes away. + */ + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log_OC.d(TAG, "Starting command with id " + startId); + + // WIP: for the moment, only SYNC_FOLDER is expected here; + // the rest of the operations are requested through the Binder + if (intent != null && ACTION_SYNC_FOLDER.equals(intent.getAction())) { + + if (!intent.hasExtra(EXTRA_ACCOUNT) || !intent.hasExtra(EXTRA_REMOTE_PATH)) { + Log_OC.e(TAG, "Not enough information provided in intent"); + return START_NOT_STICKY; + } + + Account account = IntentExtensionsKt.getParcelableArgument(intent, EXTRA_ACCOUNT, Account.class); + String remotePath = intent.getStringExtra(EXTRA_REMOTE_PATH); + + Pair itemSyncKey = new Pair<>(account, remotePath); + + Pair itemToQueue = newOperation(intent); + if (itemToQueue != null) { + mSyncFolderHandler.add(account, + remotePath, + (SynchronizeFolderOperation) itemToQueue.second); + Message msg = mSyncFolderHandler.obtainMessage(); + msg.arg1 = startId; + msg.obj = itemSyncKey; + mSyncFolderHandler.sendMessage(msg); + } + + } else { + Message msg = mOperationsHandler.obtainMessage(); + msg.arg1 = startId; + mOperationsHandler.sendMessage(msg); + } + + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + Log_OC.v(TAG, "Destroying service"); + // Saving cookies + OwnCloudClientManagerFactory.getDefaultSingleton() + .saveAllClients(this, MainApp.getAccountType(getApplicationContext())); + + mUndispatchedFinishedOperations.clear(); + + mOperationsBinder = null; + + mOperationsHandler.getLooper().quit(); + mOperationsHandler = null; + + mSyncFolderHandler.getLooper().quit(); + mSyncFolderHandler = null; + + super.onDestroy(); + } + + /** + * Provides a binder object that clients can use to perform actions on the queue of operations, except the addition + * of new operations. + */ + @Override + public IBinder onBind(Intent intent) { + return mOperationsBinder; + } + + + /** + * Called when ALL the bound clients were unbound. + */ + @Override + public boolean onUnbind(Intent intent) { + mOperationsBinder.clearListeners(); + return false; // not accepting rebinding (default behaviour) + } + + + /** + * Binder to let client components to perform actions on the queue of operations. + *

+ * It provides by itself the available operations. + */ + public class OperationsServiceBinder extends Binder /* implements OnRemoteOperationListener */ { + + /** + * Map of listeners that will be reported about the end of operations from a {@link OperationsServiceBinder} + * instance + */ + private final ConcurrentMap mBoundListeners = new ConcurrentHashMap<>(); + + private final ServiceHandler mServiceHandler; + + public OperationsServiceBinder(ServiceHandler serviceHandler) { + mServiceHandler = serviceHandler; + } + + + /** + * Cancels a pending or current synchronization. + * + * @param account ownCloud account where the remote folder is stored. + * @param file A folder in the queue of pending synchronizations + */ + public void cancel(Account account, OCFile file) { + mSyncFolderHandler.cancel(account, file); + } + + + public void clearListeners() { + + mBoundListeners.clear(); + } + + + /** + * Adds a listener interested in being reported about the end of operations. + * + * @param listener Object to notify about the end of operations. + * @param callbackHandler {@link Handler} to access the listener without breaking Android threading protection. + */ + public void addOperationListener(OnRemoteOperationListener listener, + Handler callbackHandler) { + synchronized (mBoundListeners) { + mBoundListeners.put(listener, callbackHandler); + } + } + + + /** + * Removes a listener from the list of objects interested in the being reported about the end of operations. + * + * @param listener Object to notify about progress of transfer. + */ + public void removeOperationListener(OnRemoteOperationListener listener) { + synchronized (mBoundListeners) { + mBoundListeners.remove(listener); + } + } + + + /** + * TODO - IMPORTANT: update implementation when more operations are moved into the service + * + * @return 'True' when an operation that enforces the user to wait for completion is in process. + */ + public boolean isPerformingBlockingOperation() { + return !mServiceHandler.mPendingOperations.isEmpty(); + } + + + /** + * Creates and adds to the queue a new operation, as described by operationIntent. + *

+ * Calls startService to make the operation is processed by the ServiceHandler. + * + * @param operationIntent Intent describing a new operation to queue and execute. + * @return Identifier of the operation created, or null if failed. + */ + public long queueNewOperation(Intent operationIntent) { + Pair itemToQueue = newOperation(operationIntent); + if (itemToQueue != null) { + mServiceHandler.mPendingOperations.add(itemToQueue); + startService(new Intent(OperationsService.this, OperationsService.class)); + return itemToQueue.second.hashCode(); + + } else { + return Long.MAX_VALUE; + } + } + + public boolean dispatchResultIfFinished(int operationId, + OnRemoteOperationListener listener) { + Pair undispatched = + mUndispatchedFinishedOperations.remove(operationId); + if (undispatched != null) { + listener.onRemoteOperationFinish(undispatched.first, undispatched.second); + return true; + } else { + return !mServiceHandler.mPendingOperations.isEmpty(); + } + } + + /** + * Returns True when the file described by 'file' in the ownCloud account 'account' is downloading or waiting to + * download. + *

+ * If 'file' is a directory, returns 'true' if some of its descendant files is downloading or waiting to + * download. + * + * @param user user where the remote file is stored. + * @param file File to check if something is synchronizing / downloading / uploading inside. + */ + public boolean isSynchronizing(User user, OCFile file) { + return mSyncFolderHandler.isSynchronizing(user, file.getRemotePath()); + } + + } + + + /** + * Operations worker. Performs the pending operations in the order they were requested. + *

+ * Created with the Looper of a new thread, started in {@link OperationsService#onCreate()}. + */ + private static class ServiceHandler extends Handler { + // don't make it a final class, and don't remove the static ; lint will warn about a possible memory leak + + OperationsService mService; + + + private final ConcurrentLinkedQueue> mPendingOperations = + new ConcurrentLinkedQueue<>(); + private RemoteOperation mCurrentOperation; + private Target mLastTarget; + private OwnCloudClient mOwnCloudClient; + + public ServiceHandler(Looper looper, OperationsService service) { + super(looper); + if (service == null) { + throw new IllegalArgumentException("Received invalid NULL in parameter 'service'"); + } + mService = service; + } + + @Override + public void handleMessage(Message msg) { + nextOperation(); + Log_OC.d(TAG, "Stopping after command with id " + msg.arg1); + mService.stopSelf(msg.arg1); + } + + /** + * Performs the next operation in the queue + */ + private void nextOperation() { + + //Log_OC.e(TAG, "nextOperation init" ); + + Pair next; + synchronized (mPendingOperations) { + next = mPendingOperations.peek(); + } + + if (next != null) { + mCurrentOperation = next.second; + RemoteOperationResult result; + OwnCloudAccount ocAccount = null; + + try { + /// prepare client object to send the request to the ownCloud server + if (mLastTarget == null || !mLastTarget.equals(next.first)) { + mLastTarget = next.first; + if (mLastTarget.mAccount != null) { + ocAccount = new OwnCloudAccount(mLastTarget.mAccount, mService); + } else { + ocAccount = new OwnCloudAccount(mLastTarget.mServerUrl, null); + } + mOwnCloudClient = OwnCloudClientManagerFactory.getDefaultSingleton(). + getClientFor(ocAccount, mService); + } + + // perform the operation + try { + result = mCurrentOperation.execute(mOwnCloudClient); + if (!result.isSuccess()) { + final var code = "code: " + result.getCode(); + final var httpCode = "HTTP_CODE: " + result.getHttpCode(); + Log_OC.e(TAG,"Operation failed " + code + httpCode); + } + } catch (UnsupportedOperationException e) { + // TODO remove - added to aid in transition to NextcloudClient + + if (ocAccount == null) { + throw e; + } + + NextcloudClient nextcloudClient = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(ocAccount, mService.getBaseContext()); + result = mCurrentOperation.run(nextcloudClient); + } + } catch (AccountsException | IOException e) { + if (mLastTarget.mAccount == null) { + Log_OC.e(TAG, "Error while trying to get authorization for a NULL account", + e); + } else { + Log_OC.e(TAG, "Error while trying to get authorization for " + + mLastTarget.mAccount.name, e); + } + result = new RemoteOperationResult(e); + + } catch (Exception e) { + if (mLastTarget.mAccount == null) { + Log_OC.e(TAG, "Unexpected error for a NULL account", e); + } else { + Log_OC.e(TAG, "Unexpected error for " + mLastTarget.mAccount.name, e); + } + result = new RemoteOperationResult(e); + + } finally { + synchronized (mPendingOperations) { + mPendingOperations.poll(); + } + } + + //sendBroadcastOperationFinished(mLastTarget, mCurrentOperation, result); + mService.dispatchResultToOperationListeners(mCurrentOperation, result); + } + } + } + + + /** + * Creates a new operation, as described by operationIntent. + *

+ * TODO - move to ServiceHandler (probably) + * + * @param operationIntent Intent describing a new operation to queue and execute. + * @return Pair with the new operation object and the information about its target server. + */ + private Pair newOperation(Intent operationIntent) { + RemoteOperation operation = null; + Target target = null; + try { + if (!operationIntent.hasExtra(EXTRA_ACCOUNT) && + !operationIntent.hasExtra(EXTRA_SERVER_URL)) { + Log_OC.e(TAG, "Not enough information provided in intent"); + + } else { + Account account = IntentExtensionsKt.getParcelableArgument(operationIntent, EXTRA_ACCOUNT, Account.class); + User user = toUser(account); + String serverUrl = operationIntent.getStringExtra(EXTRA_SERVER_URL); + target = new Target(account, (serverUrl == null) ? null : Uri.parse(serverUrl)); + + String action = operationIntent.getAction(); + String remotePath; + String password; + ShareType shareType; + String newParentPath; + long shareId; + + FileDataStorageManager fileDataStorageManager = new FileDataStorageManager(user, + getContentResolver()); + + switch (action) { + case ACTION_CREATE_SHARE_VIA_LINK: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + password = operationIntent.getStringExtra(EXTRA_SHARE_PASSWORD); + if (!TextUtils.isEmpty(remotePath)) { + operation = new CreateShareViaLinkOperation(remotePath, password, fileDataStorageManager); + } + break; + + case ACTION_CREATE_SECURE_FILE_DROP: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + operation = new CreateShareViaLinkOperation(remotePath, + fileDataStorageManager, + OCShare.CREATE_PERMISSION_FLAG); + break; + + case ACTION_UPDATE_PUBLIC_SHARE: + shareId = operationIntent.getLongExtra(EXTRA_SHARE_ID, -1); + + if (shareId > 0) { + UpdateShareViaLinkOperation updateLinkOperation = + new UpdateShareViaLinkOperation(shareId, fileDataStorageManager); + + password = operationIntent.getStringExtra(EXTRA_SHARE_PASSWORD); + updateLinkOperation.setPassword(password); + + long expirationDate = operationIntent.getLongExtra(EXTRA_SHARE_EXPIRATION_DATE_IN_MILLIS, 0); + updateLinkOperation.setExpirationDateInMillis(expirationDate); + + boolean hideFileDownload = operationIntent.getBooleanExtra(EXTRA_SHARE_HIDE_FILE_DOWNLOAD, + false); + updateLinkOperation.setHideFileDownload(hideFileDownload); + + if (operationIntent.hasExtra(EXTRA_SHARE_PUBLIC_LABEL)) { + updateLinkOperation.setLabel(operationIntent.getStringExtra(EXTRA_SHARE_PUBLIC_LABEL)); + } + + operation = updateLinkOperation; + } + break; + + case ACTION_UPDATE_USER_SHARE: + shareId = operationIntent.getLongExtra(EXTRA_SHARE_ID, -1); + + if (shareId > 0) { + UpdateSharePermissionsOperation updateShare = + new UpdateSharePermissionsOperation(shareId, fileDataStorageManager); + + int permissions = operationIntent.getIntExtra(EXTRA_SHARE_PERMISSIONS, -1); + updateShare.setPermissions(permissions); + + long expirationDateInMillis = operationIntent + .getLongExtra(EXTRA_SHARE_EXPIRATION_DATE_IN_MILLIS, 0L); + updateShare.setExpirationDateInMillis(expirationDateInMillis); + + password = operationIntent.getStringExtra(EXTRA_SHARE_PASSWORD); + updateShare.setPassword(password); + + operation = updateShare; + } + break; + + case ACTION_UPDATE_SHARE_NOTE: + shareId = operationIntent.getLongExtra(EXTRA_SHARE_ID, -1); + String note = operationIntent.getStringExtra(EXTRA_SHARE_NOTE); + + if (shareId > 0) { + operation = new UpdateNoteForShareOperation(shareId, note, fileDataStorageManager); + } + break; + + case ACTION_CREATE_SHARE_WITH_SHAREE: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + String shareeName = operationIntent.getStringExtra(EXTRA_SHARE_WITH); + shareType = IntentExtensionsKt.getSerializableArgument(operationIntent, EXTRA_SHARE_TYPE, ShareType.class); + int permissions = operationIntent.getIntExtra(EXTRA_SHARE_PERMISSIONS, -1); + String noteMessage = operationIntent.getStringExtra(EXTRA_SHARE_NOTE); + String sharePassword = operationIntent.getStringExtra(EXTRA_SHARE_PASSWORD); + long expirationDateInMillis = operationIntent + .getLongExtra(EXTRA_SHARE_EXPIRATION_DATE_IN_MILLIS, 0L); + boolean hideFileDownload = operationIntent.getBooleanExtra(EXTRA_SHARE_HIDE_FILE_DOWNLOAD, + false); + String attributes = operationIntent.getStringExtra(EXTRA_SHARE_ATTRIBUTES); + + if (!TextUtils.isEmpty(remotePath)) { + CreateShareWithShareeOperation createShareWithShareeOperation = + new CreateShareWithShareeOperation(remotePath, + shareeName, + shareType, + permissions, + noteMessage, + sharePassword, + expirationDateInMillis, + hideFileDownload, + attributes, + fileDataStorageManager, + getApplicationContext(), + user, + arbitraryDataProvider); + + if (operationIntent.hasExtra(EXTRA_SHARE_PUBLIC_LABEL)) { + createShareWithShareeOperation.setLabel(operationIntent.getStringExtra(EXTRA_SHARE_PUBLIC_LABEL)); + } + operation = createShareWithShareeOperation; + } + break; + + case ACTION_UPDATE_SHARE_INFO: + shareId = operationIntent.getLongExtra(EXTRA_SHARE_ID, -1); + long shareRemoteId = operationIntent.getLongExtra(EXTRA_SHARE_REMOTE_ID, -1); + + if (shareId > 0 || shareRemoteId > 0) { + UpdateShareInfoOperation updateShare = new UpdateShareInfoOperation(shareId, + shareRemoteId, + fileDataStorageManager); + + int permissionsToChange = operationIntent.getIntExtra(EXTRA_SHARE_PERMISSIONS, -1); + updateShare.setPermissions(permissionsToChange); + + long expirationDateInMills = operationIntent + .getLongExtra(EXTRA_SHARE_EXPIRATION_DATE_IN_MILLIS, 0L); + updateShare.setExpirationDateInMillis(expirationDateInMills); + + password = operationIntent.getStringExtra(EXTRA_SHARE_PASSWORD); + updateShare.setPassword(password); + + boolean fileDownloadHide = operationIntent.getBooleanExtra(EXTRA_SHARE_HIDE_FILE_DOWNLOAD + , false); + + updateShare.setHideFileDownload(fileDownloadHide); + + if (operationIntent.hasExtra(EXTRA_SHARE_PUBLIC_LABEL)) { + updateShare.setLabel(operationIntent.getStringExtra(EXTRA_SHARE_PUBLIC_LABEL)); + } + + String shareAttributes = operationIntent.getStringExtra(EXTRA_SHARE_ATTRIBUTES); + updateShare.setAttributes(shareAttributes); + + operation = updateShare; + } + break; + + case ACTION_UNSHARE: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + shareId = operationIntent.getLongExtra(EXTRA_SHARE_ID, -1); + + if (shareId > 0) { + operation = new UnshareOperation(remotePath, + shareId, + fileDataStorageManager, + user, + getApplicationContext()); + } + break; + + case ACTION_GET_SERVER_INFO: + operation = new GetServerInfoOperation(serverUrl, this); + break; + + case ACTION_GET_USER_NAME: + operation = new GetUserInfoRemoteOperation(); + break; + + case ACTION_RENAME: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + String newName = operationIntent.getStringExtra(EXTRA_NEWNAME); + operation = new RenameFileOperation(remotePath, newName, fileDataStorageManager); + break; + + case ACTION_REMOVE: + // Remove file or folder + OCFile file = IntentExtensionsKt.getParcelableArgument(operationIntent, EXTRA_FILE, OCFile.class); + if (file == null) { + Log_OC.w(TAG, "file is null cannot remove file"); + break; + } + + boolean onlyLocalCopy = operationIntent.getBooleanExtra(EXTRA_REMOVE_ONLY_LOCAL, false); + boolean inBackground = operationIntent.getBooleanExtra(EXTRA_IN_BACKGROUND, false); + operation = new RemoveFileOperation(file, + onlyLocalCopy, + user, + inBackground, + getApplicationContext(), + fileDataStorageManager); + break; + + case ACTION_CREATE_FOLDER: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + boolean encrypt = operationIntent.getBooleanExtra(EXTRA_ENCRYPTED, false); + final var createFolderOperation = new CreateFolderOperation(remotePath, + user, + getApplicationContext(), + fileDataStorageManager); + createFolderOperation.setEncrypt(encrypt); + operation = createFolderOperation; + break; + + case ACTION_SYNC_FILE: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + boolean postDialogEvent = operationIntent.getBooleanExtra(EXTRA_POST_DIALOG_EVENT, true); + boolean syncFileContents = operationIntent.getBooleanExtra(EXTRA_SYNC_FILE_CONTENTS, true); + operation = new SynchronizeFileOperation(remotePath, + user, + syncFileContents, + getApplicationContext(), + fileDataStorageManager, + false, + postDialogEvent); + break; + + case ACTION_SYNC_FOLDER: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + operation = new SynchronizeFolderOperation( + this, // TODO remove this dependency from construction time + remotePath, + user, + fileDataStorageManager, + false + ); + break; + + case ACTION_MOVE_FILE: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + newParentPath = operationIntent.getStringExtra(EXTRA_NEW_PARENT_PATH); + operation = new MoveFileOperation(remotePath, newParentPath, fileDataStorageManager); + break; + + case ACTION_COPY_FILE: + remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH); + newParentPath = operationIntent.getStringExtra(EXTRA_NEW_PARENT_PATH); + operation = new CopyFileOperation(remotePath, newParentPath, fileDataStorageManager); + break; + + case ACTION_CHECK_CURRENT_CREDENTIALS: + operation = new CheckCurrentCredentialsOperation(user, fileDataStorageManager); + break; + + case ACTION_RESTORE_VERSION: + FileVersion fileVersion = IntentExtensionsKt.getParcelableArgument(operationIntent, EXTRA_FILE_VERSION, FileVersion.class); + if (fileVersion == null) { + Log_OC.w(TAG, "file version is null cannot restore file"); + break; + } + + operation = new RestoreFileVersionRemoteOperation(fileVersion.getLocalId(), + fileVersion.getFileName()); + break; + + case ACTION_UPDATE_FILES_DOWNLOAD_LIMIT: + shareId = operationIntent.getLongExtra(EXTRA_SHARE_ID, -1); + int newLimit = operationIntent.getIntExtra(EXTRA_FILES_DOWNLOAD_LIMIT, -1); + + if (shareId > 0) { + operation = new SetFilesDownloadLimitOperation(shareId, newLimit, fileDataStorageManager, getApplicationContext()); + } + break; + + default: + // do nothing + break; + } + } + + } catch (IllegalArgumentException e) { + Log_OC.e(TAG, "Bad information provided in intent: " + e.getMessage()); + operation = null; + } + + if (operation != null) { + return new Pair<>(target, operation); + } else { + return null; + } + } + + /** + * This is a temporary compatibility helper to convert legacy {@link Account} instance to new {@link User} model. + * + * @param account Account instance + * @return User model that corresponds to Account + */ + @NonNull + private User toUser(@Nullable Account account) { + String accountName = account != null ? account.name : ""; + Optional optionalUser = accountManager.getUser(accountName); + if (optionalUser.isPresent()) { + return optionalUser.get(); + } else { + return accountManager.getAnonymousUser(); + } + } + + /** + * Notifies the currently subscribed listeners about the end of an operation. + * + * @param operation Finished operation. + * @param result Result of the operation. + */ + protected void dispatchResultToOperationListeners(final RemoteOperation operation, final RemoteOperationResult result) { + int count = 0; + + if (mOperationsBinder != null) { + for (OnRemoteOperationListener listener : mOperationsBinder.mBoundListeners.keySet()) { + final Handler handler = mOperationsBinder.mBoundListeners.get(listener); + if (handler != null) { + handler.post(() -> listener.onRemoteOperationFinish(operation, result)); + count += 1; + } + } + } + + if (count == 0) { + Pair undispatched = new Pair<>(operation, result); + mUndispatchedFinishedOperations.put(operation.hashCode(), undispatched); + } + + Log_OC.d(TAG, "Called " + count + " listeners"); + } +} diff --git a/app/src/main/java/com/owncloud/android/services/SyncFolderHandler.java b/app/src/main/java/com/owncloud/android/services/SyncFolderHandler.java new file mode 100644 index 000000000000..ddce75ddc419 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/services/SyncFolderHandler.java @@ -0,0 +1,161 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.services; + +import android.accounts.Account; +import android.accounts.AccountsException; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Pair; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.files.services.IndexedForest; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.operations.SynchronizeFolderOperation; + +import java.io.IOException; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +/** + * SyncFolder worker. Performs the pending operations in the order they were requested. + * Created with the Looper of a new thread, started in + * {@link com.owncloud.android.services.OperationsService#onCreate()}. + */ +class SyncFolderHandler extends Handler { + + private static final String TAG = SyncFolderHandler.class.getSimpleName(); + + private OperationsService mService; + + private IndexedForest mPendingOperations = new IndexedForest<>(); + + private Account mCurrentAccount; + private SynchronizeFolderOperation mCurrentSyncOperation; + private FileDownloadEventBroadcaster fileDownloadEventBroadcaster; + + + public SyncFolderHandler(Looper looper, OperationsService service) { + super(looper); + if (service == null) { + throw new IllegalArgumentException("Received invalid NULL in parameter 'service'"); + } + mService = service; + + final var context = mService.getApplicationContext(); + final var broadcastManager = LocalBroadcastManager.getInstance(context); + fileDownloadEventBroadcaster = new FileDownloadEventBroadcaster(context, broadcastManager); + } + + /** + * Returns True when the folder located in 'remotePath' in the ownCloud account 'account', or any of its + * descendants, is being synchronized (or waiting for it). + * + * @param user user where the remote folder is stored. + * @param remotePath The path to a folder that could be in the queue of synchronizations. + */ + public boolean isSynchronizing(User user, String remotePath) { + if (user == null || remotePath == null) { + return false; + } + return mPendingOperations.contains(user.getAccountName(), remotePath); + } + + @Override + public void handleMessage(Message msg) { + Pair itemSyncKey = (Pair) msg.obj; + doOperation(itemSyncKey.first, itemSyncKey.second); + Log_OC.d(TAG, "Stopping after command with id " + msg.arg1); + mService.stopSelf(msg.arg1); + } + + + /** + * Performs the next operation in the queue + */ + private void doOperation(Account account, String remotePath) { + + mCurrentSyncOperation = mPendingOperations.get(account.name, remotePath); + + if (mCurrentSyncOperation != null) { + RemoteOperationResult result; + + try { + + if (mCurrentAccount == null || !mCurrentAccount.equals(account)) { + mCurrentAccount = account; + } + + // always get client from client manager, to get fresh credentials in case of update + OwnCloudAccount ocAccount = new OwnCloudAccount(account, mService); + OwnCloudClient mOwnCloudClient = OwnCloudClientManagerFactory.getDefaultSingleton(). + getClientFor(ocAccount, mService); + + result = mCurrentSyncOperation.execute(mOwnCloudClient); + fileDownloadEventBroadcaster.sendDownloadCompleted(account.name, remotePath, mService.getPackageName(), result.isSuccess()); + mService.dispatchResultToOperationListeners(mCurrentSyncOperation, result); + } catch (AccountsException | IOException e) { + fileDownloadEventBroadcaster.sendDownloadCompleted(account.name, remotePath, mService.getPackageName(), false); + mService.dispatchResultToOperationListeners(mCurrentSyncOperation, new RemoteOperationResult<>(e)); + Log_OC.e(TAG, "Error while trying to get authorization", e); + } finally { + mPendingOperations.removePayload(account.name, remotePath); + } + } + } + + public void add(Account account, String remotePath, + SynchronizeFolderOperation syncFolderOperation){ + Pair putResult = mPendingOperations.putIfAbsent(account.name, remotePath, syncFolderOperation); + if (putResult != null) { + fileDownloadEventBroadcaster.sendDownloadEnqueued(account.name, + remotePath, + mService.getPackageName(), + syncFolderOperation.getFolderId(), + syncFolderOperation.getAccountName()); + } + } + + + /** + * Cancels a pending or current sync' operation. + * + * @param account ownCloud {@link Account} where the remote file is stored. + * @param file A file in the queue of pending synchronizations + */ + public void cancel(Account account, OCFile file){ + if (account == null || file == null) { + Log_OC.e(TAG, "Cannot cancel with NULL parameters"); + return; + } + Pair removeResult = mPendingOperations.remove(account.name, + file.getRemotePath()); + SynchronizeFolderOperation synchronization = removeResult.first; + if (synchronization != null) { + synchronization.cancel(); + } else { + // TODO synchronize? + if (mCurrentSyncOperation != null && mCurrentAccount != null && + mCurrentSyncOperation.getRemotePath().startsWith(file.getRemotePath()) && + account.name.equals(mCurrentAccount.name)) { + mCurrentSyncOperation.cancel(); + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/syncadapter/AbstractOwnCloudSyncAdapter.java b/app/src/main/java/com/owncloud/android/syncadapter/AbstractOwnCloudSyncAdapter.java new file mode 100644 index 000000000000..42258deea1b0 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/syncadapter/AbstractOwnCloudSyncAdapter.java @@ -0,0 +1,112 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-FileCopyrightText: 2011-2012 Bartosz Przybylski + * SPDX-FileCopyrightText: 2011 Sven Aßmann + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.syncadapter; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.Context; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException; + +import java.io.IOException; + +/** + * Base synchronization adapter for Nextcloud designed to be subclassed for different + * resource types, like FileSync, ContactsSync, CalendarSync, etc. + * Implements the standard {@link AbstractThreadedSyncAdapter}. + */ +abstract class AbstractOwnCloudSyncAdapter extends + AbstractThreadedSyncAdapter { + + private AccountManager accountManager; + private Account account; + private ContentProviderClient contentProviderClient; + private FileDataStorageManager storageManager; + private final UserAccountManager userAccountManager; + private OwnCloudClient client; + + AbstractOwnCloudSyncAdapter(Context context, boolean autoInitialize, UserAccountManager userAccountManager) { + super(context, autoInitialize); + this.setAccountManager(AccountManager.get(context)); + this.userAccountManager = userAccountManager; + } + + AbstractOwnCloudSyncAdapter(Context context, + boolean autoInitialize, + boolean allowParallelSyncs, + UserAccountManager userAccountManager) { + super(context, autoInitialize, allowParallelSyncs); + this.setAccountManager(AccountManager.get(context)); + this.userAccountManager = userAccountManager; + } + + void initClientForCurrentAccount() throws OperationCanceledException, + AuthenticatorException, IOException, AccountNotFoundException { + OwnCloudAccount ocAccount = new OwnCloudAccount(account, getContext()); + client = OwnCloudClientManagerFactory.getDefaultSingleton(). + getClientFor(ocAccount, getContext()); + } + + public AccountManager getAccountManager() { + return this.accountManager; + } + + public Account getAccount() { + return this.account; + } + + public User getUser() { + Account account = getAccount(); + String accountName = account != null ? account.name : null; + return userAccountManager.getUser(accountName).orElseGet(userAccountManager::getAnonymousUser); + } + + public ContentProviderClient getContentProviderClient() { + return this.contentProviderClient; + } + + public FileDataStorageManager getStorageManager() { + return this.storageManager; + } + + protected OwnCloudClient getClient() { + return this.client; + } + + public void setAccountManager(AccountManager accountManager) { + this.accountManager = accountManager; + } + + public void setAccount(Account account) { + this.account = account; + } + + public void setContentProviderClient(ContentProviderClient contentProviderClient) { + this.contentProviderClient = contentProviderClient; + } + + public void setStorageManager(FileDataStorageManager storageManager) { + this.storageManager = storageManager; + } +} diff --git a/src/main/java/com/owncloud/android/syncadapter/FileSyncAdapter.java b/app/src/main/java/com/owncloud/android/syncadapter/FileSyncAdapter.java similarity index 79% rename from src/main/java/com/owncloud/android/syncadapter/FileSyncAdapter.java rename to app/src/main/java/com/owncloud/android/syncadapter/FileSyncAdapter.java index a3a3913eb0f0..5a8b609465d4 100644 --- a/src/main/java/com/owncloud/android/syncadapter/FileSyncAdapter.java +++ b/app/src/main/java/com/owncloud/android/syncadapter/FileSyncAdapter.java @@ -1,25 +1,16 @@ -/** - * ownCloud Android client application - * - * @author Bartek Przybylski - * @author David A. Velasco - * Copyright (C) 2011 Bartek Przybylski - * Copyright (C) 2015 ownCloud Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . +/* + * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2013-2015 David A. Velasco + * SPDX-FileCopyrightText: 2011-2012 Bartosz Przybylski + * SPDX-FileCopyrightText: 2011 Sven Aßmann + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ - package com.owncloud.android.syncadapter; import android.accounts.Account; @@ -33,9 +24,8 @@ import android.content.Intent; import android.content.SyncResult; import android.os.Bundle; -import android.support.annotation.PluralsRes; -import android.support.v4.app.NotificationCompat; +import com.nextcloud.client.account.UserAccountManager; import com.owncloud.android.R; import com.owncloud.android.authentication.AuthenticatorActivity; import com.owncloud.android.datamodel.FileDataStorageManager; @@ -44,11 +34,12 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.operations.RefreshFolderOperation; +import com.owncloud.android.operations.SynchronizeFolderOperation; import com.owncloud.android.operations.UpdateOCVersionOperation; import com.owncloud.android.ui.activity.ErrorsWhileCopyingHandlerActivity; import com.owncloud.android.ui.notifications.NotificationUtils; import com.owncloud.android.utils.DataHolderUtil; -import com.owncloud.android.utils.ThemeUtils; +import com.owncloud.android.utils.theme.ViewThemeUtils; import org.apache.jackrabbit.webdav.DavException; @@ -58,10 +49,12 @@ import java.util.List; import java.util.Map; +import androidx.annotation.PluralsRes; +import androidx.core.app.NotificationCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + /** - * Implementation of {@link AbstractThreadedSyncAdapter} responsible for synchronizing - * ownCloud files. - * + * Implementation of {@link AbstractThreadedSyncAdapter} responsible for synchronizing Nextcloud files. * Performs a full synchronization of the account received in {@link #onPerformSync(Account, Bundle, * String, ContentProviderClient, SyncResult)}. */ @@ -71,9 +64,8 @@ public class FileSyncAdapter extends AbstractOwnCloudSyncAdapter { /** Maximum number of failed folder synchronizations that are supported before finishing * the synchronization operation */ - private static final int MAX_FAILED_RESULTS = 3; - - + private static final int MAX_FAILED_RESULTS = 3; + public static final String EVENT_FULL_SYNC_START = FileSyncAdapter.class.getName() + ".EVENT_FULL_SYNC_START"; public static final String EVENT_FULL_SYNC_END = FileSyncAdapter.class.getName() + @@ -89,53 +81,65 @@ public class FileSyncAdapter extends AbstractOwnCloudSyncAdapter { /** Time stamp for the current synchronization process, used to distinguish fresh data */ private long mCurrentSyncTime; - + /** Flag made 'true' when a request to cancel the synchronization is received */ private boolean mCancellation; /** Counter for failed operations in the synchronization process */ private int mFailedResultsCounter; - + /** Result of the last failed operation */ private RemoteOperationResult mLastFailedResult; - + /** Counter of conflicts found between local and remote files */ private int mConflictsFound; - - /** Counter of failed operations in synchronization of kept-in-sync files */ + + /** + * Counter of failed operations in synchronization of kept-in-sync files + */ private int mFailsInFavouritesFound; - - /** Map of remote and local paths to files that where locally stored in a location out - * of the ownCloud folder and couldn't be copied automatically into it */ + + /** + * Map of remote and local paths to files that where locally stored in a location out of the ownCloud folder and + * couldn't be copied automatically into it + */ private Map mForgottenLocalFiles; - /** {@link SyncResult} instance to return to the system when the synchronization finish */ + /** + * {@link SyncResult} instance to return to the system when the synchronization finish + */ private SyncResult mSyncResult; - /** 'True' means that the server supports the share API */ - private boolean mIsShareSupported; - - + private final ViewThemeUtils viewThemeUtils; + /** * Creates a {@link FileSyncAdapter} - * + *

* {@inheritDoc} */ - public FileSyncAdapter(Context context, boolean autoInitialize) { - super(context, autoInitialize); + public FileSyncAdapter(Context context, + boolean autoInitialize, + UserAccountManager userAccountManager, + final ViewThemeUtils viewThemeUtils) { + super(context, autoInitialize, userAccountManager); + this.viewThemeUtils = viewThemeUtils; } - /** * Creates a {@link FileSyncAdapter} - * + *

* {@inheritDoc} */ - public FileSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { - super(context, autoInitialize, allowParallelSyncs); + public FileSyncAdapter(Context context, + boolean autoInitialize, + boolean allowParallelSyncs, + UserAccountManager userAccountManager, + final ViewThemeUtils viewThemeUtils) { + super(context, autoInitialize, allowParallelSyncs, userAccountManager); + this.viewThemeUtils = viewThemeUtils; } - + /** * {@inheritDoc} */ @@ -145,22 +149,19 @@ public synchronized void onPerformSync(Account account, Bundle extras, SyncResult syncResult) { mCancellation = false; - /* When 'true' the process was requested by the user through the user interface; - when 'false', it was requested automatically by the system */ - boolean mIsManualSync = extras.getBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, false); mFailedResultsCounter = 0; mLastFailedResult = null; mConflictsFound = 0; mFailsInFavouritesFound = 0; - mForgottenLocalFiles = new HashMap(); + mForgottenLocalFiles = new HashMap<>(); mSyncResult = syncResult; mSyncResult.fullSyncRequested = false; mSyncResult.delayUntil = (System.currentTimeMillis()/1000) + 3*60*60; // avoid too many automatic synchronizations this.setAccount(account); this.setContentProviderClient(providerClient); - this.setStorageManager(new FileDataStorageManager(account, providerClient)); - + this.setStorageManager(new FileDataStorageManager(getUser(), providerClient)); + try { this.initClientForCurrentAccount(); } catch (IOException | AccountsException e) { @@ -174,29 +175,31 @@ public synchronized void onPerformSync(Account account, Bundle extras, Log_OC.d(TAG, "Synchronization of ownCloud account " + account.name + " starting"); sendLocalBroadcast(EVENT_FULL_SYNC_START, null, null); // message to signal the start // of the synchronization to the UI - + + /* When 'true' the process was requested by the user through the user interface; + when 'false', it was requested automatically by the system */ + boolean mIsManualSync = extras.getBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, false); try { updateOCVersion(); mCurrentSyncTime = System.currentTimeMillis(); if (!mCancellation) { synchronizeFolder(getStorageManager().getFileByPath(OCFile.ROOT_PATH)); - + } else { Log_OC.d(TAG, "Leaving synchronization before synchronizing the root folder " + "because cancellation request"); } - - + } finally { // it's important making this although very unexpected errors occur; // that's the reason for the finally - + if (mFailedResultsCounter > 0 && mIsManualSync) { /// don't let the system synchronization manager retries MANUAL synchronizations // (be careful: "MANUAL" currently includes the synchronization requested when // a new account is created and when the user changes the current account) mSyncResult.tooManyRetries = true; - + /// notify the user about the failure of MANUAL synchronization notifyFailedSynchronization(); } @@ -209,16 +212,16 @@ public synchronized void onPerformSync(Account account, Bundle extras, sendLocalBroadcast(EVENT_FULL_SYNC_END, null, mLastFailedResult); // message to signal // the end to the UI } - + } - + /** * Called by system SyncManager when a synchronization is required to be cancelled. - * - * Sets the mCancellation flag to 'true'. THe synchronization will be stopped later, - * before a new folder is fetched. Data of the last folder synchronized will be still - * locally saved. - * + * + * Sets the mCancellation flag to 'true'. THe synchronization will be stopped later, + * before a new folder is fetched. Data of the last folder synchronized will be still + * locally saved. + * * See {@link #onPerformSync(Account, Bundle, String, ContentProviderClient, SyncResult)} * and {@link #synchronizeFolder(OCFile)}. */ @@ -228,58 +231,53 @@ public void onSyncCanceled() { mCancellation = true; super.onSyncCanceled(); } - - + + /** * Updates the locally stored version value of the ownCloud server */ private void updateOCVersion() { - UpdateOCVersionOperation update = new UpdateOCVersionOperation(getAccount(), getContext()); + UpdateOCVersionOperation update = new UpdateOCVersionOperation(getUser(), getContext()); RemoteOperationResult result = update.execute(getClient()); if (!result.isSuccess()) { - mLastFailedResult = result; - } else { - mIsShareSupported = update.getOCVersion().isSharedSupported(); + mLastFailedResult = result; } } - - + + /** * Synchronizes the list of files contained in a folder identified with its remote path. - * - * Fetches the list and properties of the files contained in the given folder, including their + * + * Fetches the list and properties of the files contained in the given folder, including their * properties, and updates the local database with them. - * + * * Enters in the child folders to synchronize their contents also, following a recursive - * depth first strategy. - * + * depth first strategy. + * * @param folder Folder to synchronize. */ private void synchronizeFolder(OCFile folder) { - + if (mFailedResultsCounter > MAX_FAILED_RESULTS || isFinisher(mLastFailedResult)) { return; } - + // folder synchronization - RefreshFolderOperation synchFolderOp = new RefreshFolderOperation( folder, - mCurrentSyncTime, - true, - mIsShareSupported, - false, - getStorageManager(), - getAccount(), - getContext() - ); + RefreshFolderOperation synchFolderOp = new RefreshFolderOperation(folder, + mCurrentSyncTime, + true, + false, + getStorageManager(), + getUser(), + getContext()); RemoteOperationResult result = synchFolderOp.execute(getClient()); - - + // synchronized folder -> notice to UI - ALWAYS, although !result.isSuccess sendLocalBroadcast(EVENT_FULL_SYNC_FOLDER_CONTENTS_SYNCED, folder.getRemotePath(), result); - + // check the result of synchronizing the folder if (result.isSuccess() || result.getCode() == ResultCode.SYNC_CONFLICT) { - + if (result.getCode() == ResultCode.SYNC_CONFLICT) { mConflictsFound += synchFolderOp.getConflictsFound(); mFailsInFavouritesFound += synchFolderOp.getFailsInKeptInSyncFound(); @@ -288,21 +286,21 @@ private void synchronizeFolder(OCFile folder) { mForgottenLocalFiles.putAll(synchFolderOp.getForgottenLocalFiles()); } if (result.isSuccess()) { - // synchronize children folders + // synchronize children folders List children = synchFolderOp.getChildren(); // beware of the 'hidden' recursion here! syncChildren(children); } - + } else if (result.getCode() != ResultCode.FILE_NOT_FOUND) { // in failures, the statistics for the global result are updated - if (RemoteOperationResult.ResultCode.UNAUTHORIZED.equals(result.getCode())) { + if (ResultCode.UNAUTHORIZED == result.getCode()) { mSyncResult.stats.numAuthExceptions++; - + } else if (result.getException() instanceof DavException) { mSyncResult.stats.numParseExceptions++; - - } else if (result.getException() instanceof IOException) { + + } else if (result.getException() instanceof IOException) { mSyncResult.stats.numIoExceptions++; } mFailedResultsCounter++; @@ -311,24 +309,24 @@ private void synchronizeFolder(OCFile folder) { } // else, ResultCode.FILE_NOT_FOUND is ignored, remote folder was // removed from other thread or other client during the synchronization, // before this thread fetched its contents - + } /** * Checks if a failed result should terminate the synchronization process immediately, * according to OUR OWN POLICY - * + * * @param failedResult Remote operation result to check. * @return 'True' if the result should immediately finish the * synchronization */ private boolean isFinisher(RemoteOperationResult failedResult) { - if (failedResult != null) { + if (failedResult != null) { RemoteOperationResult.ResultCode code = failedResult.getCode(); - return (code.equals(RemoteOperationResult.ResultCode.SSL_ERROR) || - code.equals(RemoteOperationResult.ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED) || - code.equals(RemoteOperationResult.ResultCode.BAD_OC_VERSION) || - code.equals(RemoteOperationResult.ResultCode.INSTANCE_NOT_CONFIGURED)); + return code == ResultCode.SSL_ERROR + || code == ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED + || code == ResultCode.BAD_OC_VERSION + || code == ResultCode.INSTANCE_NOT_CONFIGURED; } return false; } @@ -338,7 +336,7 @@ private boolean isFinisher(RemoteOperationResult failedResult) { * * No consideration of etag here because it MUST walk down anyway, in case that kept-in-sync files * have local changes. - * + * * @param files Files to recursively synchronize. */ private void syncChildren(List files) { @@ -350,22 +348,22 @@ private void syncChildren(List files) { synchronizeFolder(newFile); } } - + if (mCancellation && i 0) { NotificationCompat.Builder notificationBuilder = createNotificationBuilder(); notificationBuilder.setTicker(i18n(R.string.sync_fail_in_favourites_ticker)); - + // TODO put something smart in the contentIntent below notificationBuilder .setContentIntent(PendingIntent.getActivity( - getContext(), (int) System.currentTimeMillis(), new Intent(), 0 - )) + getContext(), (int) System.currentTimeMillis(), new Intent(), PendingIntent.FLAG_IMMUTABLE + )) .setContentTitle(i18n(R.string.sync_fail_in_favourites_ticker)) .setContentText(getQuantityString( R.plurals.sync_fail_in_favourites_content, @@ -451,54 +445,52 @@ private void notifyFailsInFavourites() { mFailedResultsCounter + mConflictsFound, mConflictsFound ) ); - + showNotification(R.string.sync_fail_in_favourites_ticker, notificationBuilder); } else { NotificationCompat.Builder notificationBuilder = createNotificationBuilder(); notificationBuilder.setTicker(i18n(R.string.sync_conflicts_in_favourites_ticker)); - + // TODO put something smart in the contentIntent below notificationBuilder .setContentIntent(PendingIntent.getActivity( - getContext(), (int) System.currentTimeMillis(), new Intent(), 0 - )) + getContext(), (int) System.currentTimeMillis(), new Intent(), PendingIntent.FLAG_IMMUTABLE + )) .setContentTitle(i18n(R.string.sync_conflicts_in_favourites_ticker)) .setContentText(i18n(R.string.sync_conflicts_in_favourites_ticker, mConflictsFound)); - + showNotification(R.string.sync_conflicts_in_favourites_ticker, notificationBuilder); - } + } } - + /** * Notifies the user about local copies of files out of the ownCloud local directory that * were 'forgotten' because copying them inside the ownCloud local directory was not possible. - * + * * We don't want links to files out of the ownCloud local directory (foreign files) anymore. * It's easy to have synchronization problems if a local file is linked to more than one * remote file. - * + * * We won't consider a synchronization as failed when foreign files can not be copied to * the ownCloud local directory. */ private void notifyForgottenLocalFiles() { NotificationCompat.Builder notificationBuilder = createNotificationBuilder(); notificationBuilder.setTicker(i18n(R.string.sync_foreign_files_forgotten_ticker)); - + /// includes a pending intent in the notification showing a more detailed explanation Intent explanationIntent = new Intent(getContext(), ErrorsWhileCopyingHandlerActivity.class); - explanationIntent.putExtra(ErrorsWhileCopyingHandlerActivity.EXTRA_ACCOUNT, getAccount()); - ArrayList remotePaths = new ArrayList(); - ArrayList localPaths = new ArrayList(); - remotePaths.addAll(mForgottenLocalFiles.keySet()); - localPaths.addAll(mForgottenLocalFiles.values()); + explanationIntent.putExtra(ErrorsWhileCopyingHandlerActivity.EXTRA_USER, getUser()); + ArrayList remotePaths = new ArrayList<>(mForgottenLocalFiles.keySet()); + ArrayList localPaths = new ArrayList<>(mForgottenLocalFiles.values()); explanationIntent.putExtra(ErrorsWhileCopyingHandlerActivity.EXTRA_LOCAL_PATHS, localPaths); - explanationIntent.putExtra(ErrorsWhileCopyingHandlerActivity.EXTRA_REMOTE_PATHS, remotePaths); + explanationIntent.putExtra(ErrorsWhileCopyingHandlerActivity.EXTRA_REMOTE_PATHS, remotePaths); explanationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - + notificationBuilder .setContentIntent(PendingIntent.getActivity( - getContext(), (int) System.currentTimeMillis(), explanationIntent, 0 - )) + getContext(), (int) System.currentTimeMillis(), explanationIntent, PendingIntent.FLAG_IMMUTABLE + )) .setContentTitle(i18n(R.string.sync_foreign_files_forgotten_ticker)) .setContentText(getQuantityString( R.plurals.sync_foreign_files_forgotten_content, @@ -506,41 +498,37 @@ private void notifyForgottenLocalFiles() { mForgottenLocalFiles.size(), i18n(R.string.app_name)) ); - + showNotification(R.string.sync_foreign_files_forgotten_ticker, notificationBuilder); } - + /** * Creates a notification builder with some commonly used settings - * + * * @return notification builder */ private NotificationCompat.Builder createNotificationBuilder() { NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(getContext()); notificationBuilder.setSmallIcon(R.drawable.notification_icon).setAutoCancel(true); - notificationBuilder.setColor(ThemeUtils.primaryColor()); + viewThemeUtils.androidx.themeNotificationCompatBuilder(getContext(), notificationBuilder); return notificationBuilder; } - + /** * Builds and shows the notification - * + * * @param id * @param builder */ private void showNotification(int id, NotificationCompat.Builder builder) { - NotificationManager notificationManager = ((NotificationManager) getContext(). - getSystemService(Context.NOTIFICATION_SERVICE)); - - if ((android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O)) { - builder.setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_FILE_SYNC); - } - + NotificationManager notificationManager = (NotificationManager) getContext(). + getSystemService(Context.NOTIFICATION_SERVICE); + builder.setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_FILE_SYNC); notificationManager.notify(id, builder.build()); } /** * Shorthand translation - * + * * @param key * @param args * @return diff --git a/app/src/main/java/com/owncloud/android/syncadapter/FileSyncService.java b/app/src/main/java/com/owncloud/android/syncadapter/FileSyncService.java new file mode 100644 index 000000000000..50dcfe86e696 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/syncadapter/FileSyncService.java @@ -0,0 +1,60 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2019 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2013 David A. Velasco + * SPDX-FileCopyrightText: 2011-2012 Bartosz Przybylski + * SPDX-FileCopyrightText: 2011 Sven Aßmann + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.syncadapter; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import com.nextcloud.client.account.UserAccountManager; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import javax.inject.Inject; + +import dagger.android.AndroidInjection; + +/** + * Background service for synchronizing remote files with their local state. + *

+ * Serves as a connector to an instance of {@link FileSyncAdapter}, as required by standard Android APIs. + */ +public class FileSyncService extends Service { + + // Storage for an instance of the sync adapter + private static FileSyncAdapter syncAdapter; + // Object to use as a thread-safe lock + private static final Object syncAdapterLock = new Object(); + + @Inject UserAccountManager userAccountManager; + @Inject ViewThemeUtils viewThemeUtils; + + /** + * {@inheritDoc} + */ + @Override + public void onCreate() { + AndroidInjection.inject(this); + synchronized (syncAdapterLock) { + if (syncAdapter == null) { + syncAdapter = new FileSyncAdapter(getApplicationContext(), true, userAccountManager, viewThemeUtils); + } + } + } + + /** + * {@inheritDoc} + */ + @Override + public IBinder onBind(Intent intent) { + return syncAdapter.getSyncAdapterBinder(); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt new file mode 100644 index 000000000000..27beb655fb0f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt @@ -0,0 +1,180 @@ +/* + * Nextcloud Android client application + * + * SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui + +import android.content.Context +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.RelativeLayout +import androidx.annotation.Px +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.User +import com.nextcloud.utils.GlideHelper.loadCircularBitmapIntoImageView +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.lib.resources.shares.ShareeUser +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlin.math.min + +@Suppress("MagicNumber") +class AvatarGroupLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes), + AvatarGenerationListener { + private val borderDrawable = ContextCompat.getDrawable(context, R.drawable.round_bgnd) + + @Px + private val avatarSize: Int = DisplayUtils.convertDpToPixel(40f, context) + + @Px + private val avatarBorderSize: Int = DisplayUtils.convertDpToPixel(2f, context) + + @Px + private val overlapPx: Int = DisplayUtils.convertDpToPixel(24f, context) + + init { + checkNotNull(borderDrawable) + DrawableCompat.setTint(borderDrawable, ContextCompat.getColor(context, R.color.bg_default)) + } + + @Suppress("LongMethod", "TooGenericExceptionCaught") + fun setAvatars(user: User, sharees: MutableList, viewThemeUtils: ViewThemeUtils) { + val context = getContext() + removeAllViews() + var avatarLayoutParams: LayoutParams? + val shareeSize = min(sharees.size, MAX_AVATAR_COUNT) + val resources = context.resources + val avatarRadius = resources.getDimension(R.dimen.list_item_avatar_icon_radius) + var sharee: ShareeUser + + var avatarCount = 0 + while (avatarCount < shareeSize) { + avatarLayoutParams = LayoutParams(avatarSize, avatarSize).apply { + setMargins(0, 0, avatarCount * overlapPx, 0) + addRule(ALIGN_PARENT_RIGHT) + } + + val avatar = ImageView(context).apply { + layoutParams = avatarLayoutParams + setPadding(avatarBorderSize, avatarBorderSize, avatarBorderSize, avatarBorderSize) + scaleType = ImageView.ScaleType.CENTER_INSIDE + background = borderDrawable + } + + addView(avatar) + avatar.requestLayout() + + if (avatarCount == 0 && sharees.size > MAX_AVATAR_COUNT) { + avatar.setImageResource(R.drawable.ic_people) + viewThemeUtils.platform.tintDrawable(context, avatar.drawable, ColorRole.ON_SURFACE) + } else { + sharee = sharees[avatarCount] + when (sharee.shareType) { + ShareType.GROUP, ShareType.EMAIL, ShareType.ROOM, ShareType.CIRCLE -> + viewThemeUtils.files.createAvatar( + sharee.shareType, + avatar, + context + ) + + ShareType.FEDERATED, ShareType.FEDERATED_GROUP -> showFederatedShareAvatar( + context, + sharee.userId!!, + avatarRadius, + resources, + avatar, + viewThemeUtils + ) + + else -> { + avatar.tag = sharee + DisplayUtils.setAvatar( + user, + sharee.userId!!, + sharee.displayName, + this, + avatarRadius, + resources, + avatar, + context, + avatarBorderSize + ) + } + } + } + avatarCount++ + } + + // Recalculate container size based on avatar count + val size = overlapPx * (avatarCount - 1) + avatarSize + val rememberParam = layoutParams + rememberParam.width = size + layoutParams = rememberParam + } + + @Suppress("TooGenericExceptionCaught") + private fun showFederatedShareAvatar( + context: Context, + user: String, + avatarRadius: Float, + resources: Resources, + avatar: ImageView, + viewThemeUtils: ViewThemeUtils + ) { + val split = user.split("@") + val userId = split.getOrNull(0) ?: user + val server = split.getOrNull(1) + + val url = if (server != null) { + "https://$server/index.php/avatar/$userId/${resources.getInteger(R.integer.file_avatar_px)}" + } else { + // fallback: no federated server, maybe use local avatar + null + } + + val placeholder: Drawable = try { + TextDrawable.createAvatarByUserId(userId, avatarRadius) + } catch (e: Exception) { + Log_OC.e(TAG, "Error calculating RGB value for active account icon.", e) + viewThemeUtils.platform.colorDrawable( + ResourcesCompat + .getDrawable(resources, R.drawable.account_circle_white, null)!!, + ContextCompat.getColor(context, R.color.black) + ) + } + + avatar.tag = null + if (url != null) { + loadCircularBitmapIntoImageView(context, url, avatar, placeholder) + } else { + avatar.setImageDrawable(placeholder) + } + } + + override fun avatarGenerated(avatarDrawable: Drawable?, callContext: Any) { + (callContext as ImageView).setImageDrawable(avatarDrawable) + } + + override fun shouldCallGeneratedCallback(tag: String?, callContext: Any): Boolean = + (callContext as ImageView).tag == tag + + companion object { + private val TAG: String = AvatarGroupLayout::class.java.simpleName + private const val MAX_AVATAR_COUNT = 3 + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/CompletionCallback.kt b/app/src/main/java/com/owncloud/android/ui/CompletionCallback.kt new file mode 100644 index 000000000000..3bb0a84fd756 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/CompletionCallback.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui + +interface CompletionCallback { + fun onComplete(value: Boolean) +} diff --git a/app/src/main/java/com/owncloud/android/ui/EmptyRecyclerView.kt b/app/src/main/java/com/owncloud/android/ui/EmptyRecyclerView.kt new file mode 100644 index 000000000000..3ed4fd9ecbd4 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/EmptyRecyclerView.kt @@ -0,0 +1,103 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.lib.common.utils.Log_OC + +class EmptyRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : + RecyclerView(context, attrs, defStyle) { + companion object { + private const val TAG = "EmptyRecyclerView" + } + + private var emptyView: View? = null + private var hasFooter = false + private var previousVisibilityState: Boolean? = null + + override fun setAdapter(adapter: Adapter<*>?) { + this.adapter?.unregisterAdapterDataObserver(observer) + super.setAdapter(adapter) + adapter?.registerAdapterDataObserver(observer) + previousVisibilityState = null + configureEmptyView() + } + + fun setEmptyView(view: View?) { + emptyView = view + } + + @Suppress("ReturnCount") + private fun configureEmptyView() { + val view = emptyView ?: run { + Log_OC.e(TAG, "cannot configure empty view, view is null") + return + } + + val recyclerViewAdapter = adapter ?: run { + Log_OC.e(TAG, "cannot configure empty view, recyclerViewAdapter is null") + return + } + + val emptyCount = if (hasFooter) 1 else 0 + val empty = (recyclerViewAdapter.itemCount == emptyCount) + + if (previousVisibilityState == empty) { + Log_OC.d(TAG, "no need to configure empty view, state didn't change") + return + } + + Log_OC.d(TAG, "changing empty view state, adapter item count: ${recyclerViewAdapter.itemCount}") + + previousVisibilityState = empty + view.setVisibleIf(empty) + view.isFocusable = empty + setVisibleIf(!empty) + } + + private val observer: AdapterDataObserver = object : AdapterDataObserver() { + override fun onChanged() { + super.onChanged() + configureEmptyView() + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { + super.onItemRangeChanged(positionStart, itemCount) + configureEmptyView() + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { + super.onItemRangeChanged(positionStart, itemCount, payload) + configureEmptyView() + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + super.onItemRangeMoved(fromPosition, toPosition, itemCount) + configureEmptyView() + } + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + configureEmptyView() + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + super.onItemRangeRemoved(positionStart, itemCount) + configureEmptyView() + } + } + + fun setHasFooter(bool: Boolean) { + hasFooter = bool + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/ListPreferenceDialog.kt b/app/src/main/java/com/owncloud/android/ui/ListPreferenceDialog.kt new file mode 100644 index 000000000000..6cacb9b90173 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/ListPreferenceDialog.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui + +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.preference.ListPreference +import android.util.AttributeSet +import com.nextcloud.utils.extensions.setVisibleIf + +@Suppress("DEPRECATION") +class ListPreferenceDialog(context: Context?, attrs: AttributeSet?) : ListPreference(context, attrs) { + + fun showDialog() { + if (!isDialogCreated()) { + onClick() + } + } + + fun dismissible(value: Boolean) { + if (isDialogCreated()) { + dialog.setCancelable(value) + dialog.setCanceledOnTouchOutside(value) + } + } + + fun enableCancelButton(value: Boolean) { + if (isDialogCreated()) { + (dialog as? AlertDialog)?.let { + val cancelButton = it.getButton(Dialog.BUTTON_NEGATIVE) + cancelButton?.setVisibleIf(value) + cancelButton?.isEnabled = value + } + } + } + + private fun isDialogCreated(): Boolean = dialog != null +} diff --git a/app/src/main/java/com/owncloud/android/ui/NextcloudWebViewClient.kt b/app/src/main/java/com/owncloud/android/ui/NextcloudWebViewClient.kt new file mode 100644 index 000000000000..3e652019dcb1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/NextcloudWebViewClient.kt @@ -0,0 +1,128 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Elv1zz + * SPDX-FileCopyrightText: 2022 Unpublished + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui + +import android.annotation.SuppressLint +import android.net.http.SslCertificate +import android.net.http.SslError +import android.webkit.ClientCertRequest +import android.webkit.SslErrorHandler +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.fragment.app.FragmentManager +import com.owncloud.android.authentication.AuthenticatorActivity +import com.owncloud.android.lib.common.network.AdvancedX509KeyManager +import com.owncloud.android.lib.common.network.NetworkUtils +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.dialog.SslUntrustedCertDialog +import org.apache.commons.httpclient.HttpStatus +import java.io.ByteArrayInputStream +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +open class NextcloudWebViewClient(val supportFragmentManager: FragmentManager) : WebViewClient() { + + private val tag: String? = NextcloudWebViewClient::class.simpleName + + @Suppress("TooGenericExceptionCaught") + @SuppressLint("WebViewClientOnReceivedSslError") + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler, error: SslError?) { + val cert = error?.let { getX509CertificateFromError(it) } + try { + if (NetworkUtils.isCertInKnownServersStore(cert, view?.context?.applicationContext)) { + handler.proceed() + } else { + showUntrustedCertDialog(cert, error, handler) + } + } catch (e: Exception) { + Log_OC.e(tag, "Cert could not be verified") + } + } + + /** + * Obtain the X509Certificate from SslError + * + * @param error SslError + * @return X509Certificate from error + */ + open fun getX509CertificateFromError(error: SslError): X509Certificate? { + val bundle = SslCertificate.saveState(error.certificate) + val x509Certificate: X509Certificate? + val bytes = bundle.getByteArray("x509-certificate") + x509Certificate = if (bytes == null) { + null + } else { + try { + val certFactory = CertificateFactory.getInstance("X.509") + val cert = certFactory.generateCertificate(ByteArrayInputStream(bytes)) + cert as X509Certificate + } catch (e: CertificateException) { + null + } + } + return x509Certificate + } + + /** + * Show untrusted cert dialog + */ + private fun showUntrustedCertDialog( + x509Certificate: X509Certificate?, + error: SslError?, + handler: SslErrorHandler? + ) { + // Show a dialog with the certificate info + val dialog: SslUntrustedCertDialog = if (x509Certificate == null) { + SslUntrustedCertDialog.newInstanceForEmptySslError(error, handler) + } else { + SslUntrustedCertDialog.newInstanceForFullSslError(x509Certificate, error, handler) + } + val fm: FragmentManager = supportFragmentManager + val ft = fm.beginTransaction() + ft.addToBackStack(null) + dialog.show(ft, AuthenticatorActivity.UNTRUSTED_CERT_DIALOG_TAG) + } + + /** + * Handle request for a TLS client certificate. + */ + override fun onReceivedClientCertRequest(view: WebView?, request: ClientCertRequest?) { + if (view == null || request == null) { + return + } + AdvancedX509KeyManager(view.context).handleWebViewClientCertRequest(request) + } + + /** + * Handle HTTP errors. + * + * We might receive an HTTP status code 400 (bad request), which probably tells us that our certificate + * is not valid (anymore), e.g. because it expired. In that case we forget the selected client certificate, + * so it can be re-selected. + */ + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + val errorCode = errorResponse?.statusCode ?: return + if (errorCode == HttpStatus.SC_BAD_REQUEST) { + // chosen client certificate alias does not seem to work -> discard it + val failingUrl = request?.url + val context = view?.context + if (failingUrl == null || context == null) { + return + } + Log_OC.w(tag, "WebView failed with error code $errorCode; remove key chain aliases") + AdvancedX509KeyManager(context).removeKeys(failingUrl) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/SquareImageView.java b/app/src/main/java/com/owncloud/android/ui/SquareImageView.java new file mode 100644 index 000000000000..c4b2a1df7938 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/SquareImageView.java @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2014-2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatImageView; + +public class SquareImageView extends AppCompatImageView { + + public SquareImageView(Context context) { + super(context); + } + + public SquareImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SquareImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/SquareLinearLayout.java b/app/src/main/java/com/owncloud/android/ui/SquareLinearLayout.java new file mode 100644 index 000000000000..bca3b38ab354 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/SquareLinearLayout.java @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2014 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +public class SquareLinearLayout extends LinearLayout { + + public SquareLinearLayout(Context context) { + super(context); + } + + public SquareLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SquareLinearLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/StatusDrawable.java b/app/src/main/java/com/owncloud/android/ui/StatusDrawable.java new file mode 100644 index 000000000000..129811fcfcf1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/StatusDrawable.java @@ -0,0 +1,129 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; + +import com.owncloud.android.R; +import com.owncloud.android.lib.resources.users.Status; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * A Drawable object that draws a status + */ +@SuppressFBWarnings("PME_POOR_MANS_ENUM") +public class StatusDrawable extends Drawable { + private String text; + private @DrawableRes int icon = -1; + private Paint textPaint; + private Paint backgroundPaint; + private final float radius; + private Context context; + + public StatusDrawable(Status status, float statusSize, Context context) { + backgroundPaint = new Paint(); + backgroundPaint.setStyle(Paint.Style.FILL); + backgroundPaint.setAntiAlias(true); + + radius = statusSize; + + if (TextUtils.isEmpty(status.getIcon())) { + this.context = context; + backgroundPaint.setColor(context.getColor(R.color.bg_default)); + + switch (status.getStatus()) { + case DND: + icon = R.drawable.ic_user_status_dnd; + break; + + case BUSY: + icon = R.drawable.ic_user_status_busy; + break; + + case ONLINE: + icon = R.drawable.ic_user_status_online; + break; + + case AWAY: + icon = R.drawable.ic_user_status_away; + break; + + default: + // do not show + backgroundPaint = null; + break; + } + } else { + text = status.getIcon(); + + backgroundPaint = null; + + textPaint = new Paint(); + textPaint.setColor(Color.WHITE); + textPaint.setTextSize(statusSize); + textPaint.setAntiAlias(true); + textPaint.setTextAlign(Paint.Align.CENTER); + } + } + + /** + * Draw in its bounds (set via setBounds) respecting optional effects such as alpha (set via setAlpha) and color + * filter (set via setColorFilter) a circular background with a user's first character. + * + * @param canvas The canvas to draw into + */ + @Override + public void draw(@NonNull Canvas canvas) { + if (backgroundPaint != null) { + canvas.drawCircle(radius, radius, radius, backgroundPaint); + } + + if (text != null) { + textPaint.setTextSize(1.6f * radius); + canvas.drawText(text, radius, radius - ((textPaint.descent() + textPaint.ascent()) / 2), textPaint); + } + + if (icon != -1) { + Drawable drawable = ResourcesCompat.getDrawable(context.getResources(), icon, null); + + if (drawable != null) { + drawable.setBounds(0, + 0, + (int) (2 * radius), + (int) (2 * radius)); + drawable.draw(canvas); + } + } + } + + @Override + public void setAlpha(int alpha) { + textPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter cf) { + textPaint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/TextDrawable.java b/app/src/main/java/com/owncloud/android/ui/TextDrawable.java new file mode 100644 index 000000000000..10321efdf738 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/TextDrawable.java @@ -0,0 +1,167 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2019-2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2015-2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016-2018 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.owncloud.android.utils.BitmapUtils; + +import java.util.Locale; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +/** + * A Drawable object that draws text (1 character) on top of a circular/filled background. + */ +public class TextDrawable extends Drawable { + /** + * the text to be rendered. + */ + private String text; + + /** + * the text paint to be rendered. + */ + private Paint textPaint; + + /** + * the background to be rendered. + */ + private Paint background; + + /** + * the radius of the circular background to be rendered. + */ + private float radius; + + private boolean bigText = false; + + /** + * Create a TextDrawable with the given radius. + * + * @param text the text to be rendered + * @param color color + * @param radius circle radius + */ + public TextDrawable(String text, BitmapUtils.Color color, float radius) { + this.radius = radius; + this.text = text; + + background = new Paint(); + background.setStyle(Paint.Style.FILL); + background.setAntiAlias(true); + background.setColor(Color.argb(color.a, color.r, color.g, color.b)); + + textPaint = new Paint(); + textPaint.setColor(Color.WHITE); + textPaint.setTextSize(radius); + textPaint.setAntiAlias(true); + textPaint.setTextAlign(Paint.Align.CENTER); + + setBounds(0, 0, (int) radius * 2, (int) radius * 2); + } + + /** + * creates an avatar in form of a TextDrawable with the first letter of the account name in a circle with the given + * radius. + * + * @param user user account + * @param radiusInDp the circle's radius + * @return the avatar as a TextDrawable + */ + @NonNull + public static TextDrawable createAvatar(User user, float radiusInDp) { + String username = UserAccountManager.getDisplayName(user); + return createNamedAvatar(username, radiusInDp); + } + + /** + * creates an avatar in form of a TextDrawable with the first letter of the account name in a circle with the given + * radius. + * + * @param userId userId to use + * @param radiusInDp the circle's radius + * @return the avatar as a TextDrawable + */ + @NonNull + public static TextDrawable createAvatarByUserId(String userId, float radiusInDp) { + return createNamedAvatar(userId, radiusInDp); + } + + /** + * creates an avatar in form of a TextDrawable with the first letter of a name in a circle with the + * given radius. + * + * @param name the name + * @param radiusInDp the circle's radius + * @return the avatar as a TextDrawable + */ + @NonNull + public static TextDrawable createNamedAvatar(String name, float radiusInDp) { + BitmapUtils.Color color = BitmapUtils.usernameToColor(name); + return new TextDrawable(extractCharsFromDisplayName(name), color, radiusInDp); + } + + @VisibleForTesting + public static String extractCharsFromDisplayName(@NonNull final String displayName) { + final String trimmed = displayName.trim(); + if (trimmed.isEmpty()) { + return ""; + } + String[] nameParts = trimmed.split("\\s+"); + + StringBuilder firstTwoLetters = new StringBuilder(); + for (int i = 0; i < Math.min(2, nameParts.length); i++) { + firstTwoLetters.append(nameParts[i].substring(0, 1).toUpperCase(Locale.getDefault())); + } + + return firstTwoLetters.toString(); + } + + /** + * Draw in its bounds (set via setBounds) respecting optional effects such as alpha (set via setAlpha) and color + * filter (set via setColorFilter) a circular background with a user's first character. + * + * @param canvas The canvas to draw into + */ + @Override + public void draw(@NonNull Canvas canvas) { + canvas.drawCircle(radius, radius, radius, background); + + if (bigText) { + textPaint.setTextSize(1.8f * radius); + } + + canvas.drawText(text, radius, radius - ((textPaint.descent() + textPaint.ascent()) / 2), textPaint); + } + + @Override + public void setAlpha(int alpha) { + textPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter cf) { + textPaint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt new file mode 100644 index 000000000000..fa8ccbb332be --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt @@ -0,0 +1,54 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui + +import android.content.Context +import android.preference.SwitchPreference +import android.util.AttributeSet +import android.view.View +import com.google.android.material.materialswitch.MaterialSwitch +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +@Suppress("DEPRECATION") +class ThemeableSwitchPreference : SwitchPreference { + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + /** + * Do not delete constructor. These are used. + */ + constructor(context: Context) : super(context) { + init() + } + + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { + init() + } + + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + init() + } + + private fun init() { + MainApp.getAppComponent().inject(this) + setWidgetLayoutResource(R.layout.themeable_switch) + } + + @Deprecated("Deprecated in Java") + override fun onBindView(view: View) { + super.onBindView(view) + val checkable = view.findViewById(R.id.switch_widget) + if (checkable is MaterialSwitch) { + checkable.setChecked(isChecked) + viewThemeUtils.material.colorMaterialSwitch(checkable) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesContract.kt b/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesContract.kt new file mode 100644 index 000000000000..991c08f430a8 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesContract.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities + +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.BaseActivity +import kotlinx.coroutines.CoroutineScope + +interface ActivitiesContract { + interface View { + fun showActivities(activities: List, client: NextcloudClient, lastGiven: Long) + fun showActivitiesLoadError(error: String) + fun showActivityDetailUI(ocFile: OCFile) + fun showActivityDetailUIIsNull() + fun showActivityDetailError(error: String) + fun showLoadingMessage() + fun showEmptyContent(headline: String, message: String) + fun setProgressIndicatorState(isActive: Boolean) + } + + interface ActionListener { + fun loadActivities(lifecycleScope: CoroutineScope, lastGiven: Long) + + fun openActivity(fileUrl: String, baseActivity: BaseActivity) + + fun onStop() + + fun onResume() + + companion object { + @JvmField + val UNDEFINED: Int = -1 + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesPresenter.kt b/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesPresenter.kt new file mode 100644 index 000000000000..5f0a0a073829 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesPresenter.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities + +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository +import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository.LoadActivitiesCallback +import com.owncloud.android.ui.activities.data.files.FilesRepository +import com.owncloud.android.ui.activities.data.files.FilesRepository.ReadRemoteFileCallback +import com.owncloud.android.ui.activity.BaseActivity +import kotlinx.coroutines.CoroutineScope + +class ActivitiesPresenter internal constructor( + private val activitiesRepository: ActivitiesRepository, + private val filesRepository: FilesRepository, + private val activitiesView: ActivitiesContract.View +) : ActivitiesContract.ActionListener { + private var activityStopped = false + + override fun loadActivities(lifecycleScope: CoroutineScope, lastGiven: Long) { + if (ActivitiesContract.ActionListener.UNDEFINED.toLong() == lastGiven) { + activitiesView.showLoadingMessage() + } else { + activitiesView.setProgressIndicatorState(true) + } + activitiesRepository.getActivities( + lifecycleScope, + lastGiven, + object : LoadActivitiesCallback { + override fun onActivitiesLoaded(activities: List, client: NextcloudClient, lastGiven: Long) { + if (!activityStopped) { + activitiesView.setProgressIndicatorState(false) + activitiesView.showActivities(activities, client, lastGiven) + } + } + + override fun onActivitiesLoadedError(error: String) { + if (!activityStopped) { + activitiesView.setProgressIndicatorState(false) + activitiesView.showActivitiesLoadError(error) + } + } + } + ) + } + + override fun openActivity(fileUrl: String, baseActivity: BaseActivity) { + activitiesView.setProgressIndicatorState(true) + filesRepository.readRemoteFile( + fileUrl, + baseActivity, + object : ReadRemoteFileCallback { + override fun onFileLoaded(ocFile: OCFile?) { + activitiesView.setProgressIndicatorState(false) + if (ocFile != null) { + activitiesView.showActivityDetailUI(ocFile) + } else { + activitiesView.showActivityDetailUIIsNull() + } + } + + override fun onFileLoadError(error: String) { + activitiesView.setProgressIndicatorState(false) + activitiesView.showActivityDetailError(error) + } + } + ) + } + + override fun onStop() { + activityStopped = true + } + + override fun onResume() { + activityStopped = false + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/StickyHeaderAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/StickyHeaderAdapter.kt new file mode 100644 index 000000000000..02daa619d8d9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/StickyHeaderAdapter.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.activities + +import android.view.View + +interface StickyHeaderAdapter { + /** + * This method gets called by [StickyHeaderItemDecoration] to fetch the position of the header item in the adapter + * that is used for (represents) item at specified position. + * @param itemPosition int. Adapter's position of the item for which to do the search of the position of the header + * item. + * @return int. Position of the header item in the adapter. + */ + fun getHeaderPositionForItem(itemPosition: Int): Int + + /** + * This method gets called by [StickyHeaderItemDecoration] to get layout resource id for the header item at + * specified adapter's position. + * @param headerPosition int. Position of the header item in the adapter. + * @return int. Layout resource id. + */ + fun getHeaderLayout(headerPosition: Int): Int + + /** + * This method gets called by [StickyHeaderItemDecoration] to setup the header View. + * @param header View. Header to set the data on. + * @param headerPosition int. Position of the header item in the adapter. + */ + fun bindHeaderData(header: View?, headerPosition: Int) + + /** + * This method gets called by [StickyHeaderItemDecoration] to verify whether the item represents a header. + * @param itemPosition int. + * @return true, if item at the specified adapter's position represents a header. + */ + fun isHeader(itemPosition: Int): Boolean +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.kt b/app/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.kt new file mode 100644 index 000000000000..17616607889d --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.kt @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.activities + +import android.graphics.Canvas +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.core.graphics.withTranslation + +@Suppress("ReturnCount") +class StickyHeaderItemDecoration(private val adapter: StickyHeaderAdapter) : RecyclerView.ItemDecoration() { + + override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + super.onDrawOver(canvas, parent, state) + + val topChild = parent.getChildAt(0) ?: return + val position = parent.getChildAdapterPosition(topChild) + if (position == RecyclerView.NO_POSITION) return + + val header = getHeaderViewForItem(position, parent) + fixLayoutSize(parent, header) + + val contactPoint = header.bottom + val childInContact = getChildInContact(parent, contactPoint) ?: return + + if (adapter.isHeader(parent.getChildAdapterPosition(childInContact))) { + moveHeader(canvas, header, childInContact) + } else { + drawHeader(canvas, header) + } + } + + private fun drawHeader(canvas: Canvas, header: View) = canvas.withTranslation(0f, 0f) { header.draw(this) } + + private fun moveHeader(canvas: Canvas, currentHeader: View, nextHeader: View) = + canvas.withTranslation(0f, (nextHeader.top - currentHeader.height).toFloat()) { + currentHeader.draw(this) + } + + private fun getChildInContact(parent: RecyclerView, contactPoint: Int): View? = (0 until parent.childCount) + .map { parent.getChildAt(it) } + .firstOrNull { it.bottom > contactPoint && it.top <= contactPoint } + + private fun getHeaderViewForItem(position: Int, parent: RecyclerView): View { + val headerPos = adapter.getHeaderPositionForItem(position) + val layoutId = adapter.getHeaderLayout(position) + + return LayoutInflater.from(parent.context) + .inflate(layoutId, parent, false) + .apply { adapter.bindHeaderData(this, headerPos) } + } + + private fun fixLayoutSize(parent: ViewGroup, view: View) { + val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) + + val childWidthSpec = ViewGroup.getChildMeasureSpec( + widthSpec, + parent.paddingLeft + parent.paddingRight, + view.layoutParams.width + ) + + val childHeightSpec = ViewGroup.getChildMeasureSpec( + heightSpec, + parent.paddingTop + parent.paddingBottom, + view.layoutParams.height + ) + + view.measure(childWidthSpec, childHeightSpec) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityAndVersionListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityAndVersionListAdapter.kt new file mode 100644 index 000000000000..cd2c24c65ce3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityAndVersionListAdapter.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.activities.adapter + +import android.annotation.SuppressLint +import android.text.format.DateFormat +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.fragment.app.FragmentActivity +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.databinding.VersionListItemBinding +import com.owncloud.android.lib.resources.activities.model.Activity +import com.owncloud.android.lib.resources.files.model.FileVersion +import com.owncloud.android.ui.interfaces.ActivityListInterface +import com.owncloud.android.ui.interfaces.VersionListInterface +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.util.Date + +class ActivityAndVersionListAdapter( + context: FragmentActivity, + currentAccountProvider: CurrentAccountProvider, + activityListInterface: ActivityListInterface, + private val versionListInterface: VersionListInterface.View, + viewThemeUtils: ViewThemeUtils +) : ActivityListAdapter(context, currentAccountProvider, activityListInterface, true, viewThemeUtils) { + + @SuppressLint("NotifyDataSetChanged") + fun setActivityAndVersionItems(items: MutableList, newClient: NextcloudClient?, clear: Boolean) { + if (client == null) client = newClient + + if (clear) { + values.clear() + items.sortByDescending { it.timestamp() } + } + + var sTime = "" + for (item in items) { + val time = getHeaderDateString(context, item.timestamp() ?: continue).toString() + if (!sTime.equals(time, ignoreCase = true)) { + sTime = time + values.add(sTime) + } + if (item != null) { + values.add(item) + } + } + + notifyDataSetChanged() + } + + private fun Any?.timestamp(): Long? = when (this) { + is Activity -> datetime.time + is FileVersion -> modifiedTimestamp + else -> null + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + if (viewType == VERSION_TYPE) { + return VersionViewHolder(VersionListItemBinding.inflate(LayoutInflater.from(parent.context))) + } + return super.onCreateViewHolder(parent, viewType) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is VersionViewHolder) { + val fileVersion = values[position] as FileVersion + holder.binding.size.text = DisplayUtils.bytesToHumanReadable(fileVersion.fileLength) + holder.binding.time.text = DateFormat.format("HH:mm", Date(fileVersion.modifiedTimestamp).time) + holder.binding.restore.setOnClickListener { versionListInterface.onRestoreClicked(fileVersion) } + } else { + super.onBindViewHolder(holder, position) + } + } + + override fun getItemViewType(position: Int) = when (values[position]) { + is Activity -> ACTIVITY_TYPE + is FileVersion -> VERSION_TYPE + else -> HEADER_TYPE + } + + open class VersionViewHolder(val binding: VersionListItemBinding) : RecyclerView.ViewHolder(binding.root) + + companion object { + private const val VERSION_TYPE = 102 + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt new file mode 100644 index 000000000000..6311715cb1a6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt @@ -0,0 +1,323 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.activities.adapter + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextPaint +import android.text.format.DateFormat +import android.text.format.DateUtils +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.common.NextcloudClient +import com.nextcloud.utils.GlideHelper +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.ActivityListItemBinding +import com.owncloud.android.databinding.ActivityListItemHeaderBinding +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.activities.model.Activity +import com.owncloud.android.lib.resources.activities.model.RichElement +import com.owncloud.android.lib.resources.activities.model.RichObject +import com.owncloud.android.lib.resources.activities.models.PreviewObject +import com.owncloud.android.ui.activities.StickyHeaderAdapter +import com.owncloud.android.ui.interfaces.ActivityListInterface +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale +import kotlin.math.floor +import kotlin.math.log +import kotlin.math.pow + +@Suppress("MagicNumber") +open class ActivityListAdapter( + protected val context: FragmentActivity, + private val currentAccountProvider: CurrentAccountProvider, + private val activityListInterface: ActivityListInterface, + private val isDetailView: Boolean, + private val viewThemeUtils: ViewThemeUtils +) : RecyclerView.Adapter(), + StickyHeaderAdapter { + + protected var client: NextcloudClient? = null + val values: MutableList = mutableListOf() + private val px = getThumbnailDimension() + + @Suppress("NotifyDataSetChanged") + fun setActivityItems(activityItems: List, client: NextcloudClient, clear: Boolean) { + this.client = client + if (clear) values.clear() + + var sTime = "" + for (o in activityItems) { + val activity = o as Activity + val time = getHeaderDateString(context, activity.datetime.time).toString() + if (!sTime.equals(time, ignoreCase = true)) { + sTime = time + values.add(sTime) + } + values.add(activity) + } + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == ACTIVITY_TYPE) { + ActivityViewHolder(ActivityListItemBinding.inflate(inflater, parent, false)) + } else { + ActivityViewHeaderHolder(ActivityListItemHeaderBinding.inflate(inflater, parent, false)) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ActivityViewHolder -> bindActivityViewHolder(holder, position) + is ActivityViewHeaderHolder -> holder.binding.header.text = values[position] as String + } + } + + @Suppress("LongMethod") + private fun bindActivityViewHolder(holder: ActivityViewHolder, position: Int) { + val activity = values[position] as Activity + + holder.binding.datetime.apply { + visibility = View.VISIBLE + text = DateFormat.format("HH:mm", activity.datetime.time) + } + + when { + activity.richSubjectElement.richSubject.isNotEmpty() -> holder.binding.subject.apply { + visibility = View.VISIBLE + movementMethod = LinkMovementMethod.getInstance() + setText(addClickablePart(activity.richSubjectElement), TextView.BufferType.SPANNABLE) + } + + activity.subject.isNotEmpty() -> holder.binding.subject.apply { + visibility = View.VISIBLE + text = activity.subject + } + + else -> holder.binding.subject.visibility = View.GONE + } + + holder.binding.message.apply { + text = activity.message + visibility = if (activity.message.isNotEmpty()) View.VISIBLE else View.GONE + } + + if (activity.icon.isNotEmpty()) { + loadImageAsync(activity.icon, holder.binding.icon, R.drawable.ic_activity) + } + + val isNightMode = + (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + val isFileCreatedOrDeleted = activity.type.equals("file_created", ignoreCase = true) || + activity.type.equals("file_deleted", ignoreCase = true) + + if (!isFileCreatedOrDeleted) { + holder.binding.icon.setColorFilter( + if (isNightMode) Color.WHITE else Color.BLACK, + PorterDuff.Mode.SRC_IN + ) + } + + val richObjectList = activity.richSubjectElement.richObjectList + if (richObjectList.isNotEmpty()) { + holder.binding.list.apply { + visibility = View.VISIBLE + removeAllViews() + post { + val totalColumnCount = measuredWidth / (px + 20) + try { + columnCount = totalColumnCount + } catch (e: IllegalArgumentException) { + Log_OC.e(TAG, "error setting column count to $totalColumnCount") + } + } + activity.previews + .filter { + !isDetailView || MimeTypeUtil.isImageOrVideo(it.mimeType) || + MimeTypeUtil.isVideo(it.mimeType) + } + .forEach { addView(createThumbnail(it, richObjectList)) } + } + } else { + holder.binding.list.apply { + removeAllViews() + visibility = View.GONE + } + } + } + + private fun loadImageAsync(url: String, imageView: ImageView, @DrawableRes placeholder: Int) { + context.lifecycleScope.launch { + runCatching { + val client = withContext(Dispatchers.IO) { + OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(currentAccountProvider.user.toOwnCloudAccount(), context) + } + GlideHelper.loadIntoImageView(context, client, url, imageView, placeholder, false) + }.onFailure { + Log_OC.e(TAG, "Exception loading image: $it") + } + } + } + + private fun createThumbnail(previewObject: PreviewObject, richObjectList: List): ImageView { + val imageView = ImageView(context).apply { + layoutParams = LinearLayout.LayoutParams(px, px).apply { setMargins(10, 10, 10, 10) } + } + + richObjectList.firstOrNull { it.id?.toIntOrNull() == previewObject.fileId }?.let { richObject -> + imageView.setOnClickListener { activityListInterface.onActivityClicked(richObject) } + } + + when { + MimeTypeUtil.isImageOrVideo(previewObject.mimeType) -> { + val placeholder = + if (MimeTypeUtil.isImage(previewObject.mimeType)) R.drawable.file_image else R.drawable.file_movie + previewObject.source?.let { loadImageAsync(it, imageView, placeholder) } + } + + MimeTypeUtil.isFolder(previewObject.mimeType) -> + imageView.setImageDrawable(MimeTypeUtil.getDefaultFolderIcon(context, viewThemeUtils)) + + else -> + imageView.setImageDrawable( + MimeTypeUtil.getFileTypeIcon( + previewObject.mimeType, + "", + context, + viewThemeUtils + ) + ) + } + + return imageView + } + + private fun addClickablePart(richElement: RichElement): SpannableStringBuilder { + var text = richElement.richSubject + val ssb = SpannableStringBuilder(text) + + var idx1 = text.indexOf('{') + while (idx1 != -1) { + var idx2 = text.indexOf('}', idx1) + 1 + val richObject = richElement.richObjectList.firstOrNull { + it.tag.equals(text.substring(idx1 + 1, idx2 - 1), ignoreCase = true) + } + + if (richObject != null) { + val name = richObject.name.orEmpty() + ssb.replace(idx1, idx2, name) + text = ssb.toString() + idx2 = idx1 + name.length + + ssb.setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject) + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + }, + idx1, + idx2, + 0 + ) + ssb.setSpan(StyleSpan(Typeface.BOLD), idx1, idx2, 0) + ssb.setSpan( + ForegroundColorSpan(context.resources.getColor(R.color.text_color)), + idx1, + idx2, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + idx1 = text.indexOf('{', idx2) + } + + return ssb + } + + override fun getItemViewType(position: Int) = if (values[position] is Activity) ACTIVITY_TYPE else HEADER_TYPE + + override fun getItemCount() = values.size + + fun isEmpty() = values.isEmpty() + + private fun getThumbnailDimension(): Int { + val dimension = MainApp.getAppContext().resources.getDimension(R.dimen.file_icon_size_grid) + return (2.0.pow(floor(log(dimension.toDouble(), 2.0))) / 2).toInt() + } + + fun getHeaderDateString(context: Context, modificationTimestamp: Long): CharSequence = + if ((System.currentTimeMillis() - modificationTimestamp) < DateUtils.WEEK_IN_MILLIS) { + DisplayUtils.getRelativeDateTimeString( + context, + modificationTimestamp, + DateUtils.DAY_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0 + ) + } else { + DateFormat.format( + DateFormat.getBestDateTimePattern(Locale.getDefault(), "EEEE, MMMM d"), + modificationTimestamp + ) + } + + override fun getHeaderPositionForItem(itemPosition: Int): Int { + var pos = itemPosition + while (pos >= 0 && !isHeader(pos)) pos-- + return pos + } + + override fun getHeaderLayout(headerPosition: Int) = R.layout.activity_list_item_header + + override fun bindHeaderData(header: View?, headerPosition: Int) { + header?.findViewById(R.id.header)?.text = values[headerPosition] as String + } + + override fun isHeader(itemPosition: Int) = + itemPosition in values.indices && getItemViewType(itemPosition) == HEADER_TYPE + + protected class ActivityViewHolder(val binding: ActivityListItemBinding) : + RecyclerView.ViewHolder(binding.root) + + protected class ActivityViewHeaderHolder(val binding: ActivityListItemHeaderBinding) : + RecyclerView.ViewHolder(binding.root) + + companion object { + const val HEADER_TYPE = 100 + const val ACTIVITY_TYPE = 101 + private val TAG: String = ActivityListAdapter::class.java.simpleName + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesRepository.kt b/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesRepository.kt new file mode 100644 index 000000000000..59cd088a69b5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesRepository.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities.data.activities + +import com.nextcloud.common.NextcloudClient +import kotlinx.coroutines.CoroutineScope + +interface ActivitiesRepository { + interface LoadActivitiesCallback { + fun onActivitiesLoaded(activities: List, client: NextcloudClient, lastGiven: Long) + fun onActivitiesLoadedError(error: String) + } + + fun getActivities(lifecycleScope: CoroutineScope, lastGiven: Long, callback: LoadActivitiesCallback) +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesServiceApi.kt b/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesServiceApi.kt new file mode 100644 index 000000000000..0e3cc5817f02 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesServiceApi.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities.data.activities + +import com.nextcloud.common.NextcloudClient +import kotlinx.coroutines.CoroutineScope + +/** + * Defines an interface to the Activities service API. All ([Activity]) data requests should + * be piped through this interface. + */ +interface ActivitiesServiceApi { + interface ActivitiesServiceCallback { + fun onLoaded(activities: T, client: NextcloudClient, lastGiven: Long) + fun onError(error: String) + } + + fun getAllActivities( + lifecycleScope: CoroutineScope, + lastGiven: Long, + callback: ActivitiesServiceCallback> + ) +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesServiceApiImpl.kt b/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesServiceApiImpl.kt new file mode 100644 index 000000000000..cfec43c3420f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/activities/ActivitiesServiceApiImpl.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities.data.activities + +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.activities.GetActivitiesRemoteOperation +import com.owncloud.android.ui.activities.data.activities.ActivitiesServiceApi.ActivitiesServiceCallback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.commons.httpclient.HttpStatus + +/** + * Implementation of the Activities Service API that communicates with the NextCloud remote server + */ +@Suppress("TooGenericExceptionThrown") +class ActivitiesServiceApiImpl(private val accountManager: UserAccountManager) : ActivitiesServiceApi { + + override fun getAllActivities( + lifecycleScope: CoroutineScope, + lastGiven: Long, + callback: ActivitiesServiceCallback> + ) { + lifecycleScope.launch(Dispatchers.Main) { + val result = runCatching { + withContext(Dispatchers.IO) { fetchActivities(lastGiven) } + } + + result.fold( + onSuccess = { (activities, client, updatedLastGiven) -> + callback.onLoaded(activities, client, updatedLastGiven) + }, + onFailure = { error -> + Log_OC.e(TAG, "Failed to fetch activities", error) + callback.onError(error.message ?: "") + } + ) + } + } + + private data class ActivitiesResult(val activities: List, val client: NextcloudClient, val lastGiven: Long) + + private fun fetchActivities(lastGiven: Long): ActivitiesResult { + val context = MainApp.getAppContext() + val ocAccount = accountManager.user.toOwnCloudAccount() + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(ocAccount, context) + + val operation = if (lastGiven > 0) { + GetActivitiesRemoteOperation(lastGiven) + } else { + GetActivitiesRemoteOperation() + } + + val result = operation.execute(client) + + if (result.isSuccess && result.getData() != null) { + val data = result.getData() + val activities = data[0] as List + val updatedLastGiven = data[1] as Long + val items = activities.filterNotNull() + return ActivitiesResult(items, client, updatedLastGiven) + } + + val errorMessage = if (result.httpCode == HttpStatus.SC_NOT_MODIFIED) { + context.getString(R.string.file_list_empty_headline_server_search) + } else { + result.getLogMessage(context) + } + + Log_OC.d(TAG, result.logMessage) + throw RuntimeException(errorMessage) + } + + companion object { + private val TAG: String = ActivitiesServiceApiImpl::class.java.simpleName + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/activities/RemoteActivitiesRepository.kt b/app/src/main/java/com/owncloud/android/ui/activities/data/activities/RemoteActivitiesRepository.kt new file mode 100644 index 000000000000..cb1bc6436720 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/activities/RemoteActivitiesRepository.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities.data.activities + +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository.LoadActivitiesCallback +import com.owncloud.android.ui.activities.data.activities.ActivitiesServiceApi.ActivitiesServiceCallback +import kotlinx.coroutines.CoroutineScope + +class RemoteActivitiesRepository(private val activitiesServiceApi: ActivitiesServiceApi) : ActivitiesRepository { + override fun getActivities(lifecycleScope: CoroutineScope, lastGiven: Long, callback: LoadActivitiesCallback) { + activitiesServiceApi.getAllActivities( + lifecycleScope, + lastGiven, + object : ActivitiesServiceCallback> { + override fun onLoaded(activities: List, client: NextcloudClient, lastGiven: Long) { + callback.onActivitiesLoaded(activities, client, lastGiven) + } + + override fun onError(error: String) { + callback.onActivitiesLoadedError(error) + } + } + ) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesRepository.kt b/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesRepository.kt new file mode 100644 index 000000000000..30b851d26e99 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesRepository.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities.data.files + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.BaseActivity + +interface FilesRepository { + interface ReadRemoteFileCallback { + fun onFileLoaded(ocFile: OCFile?) + fun onFileLoadError(error: String) + } + + fun readRemoteFile(path: String, activity: BaseActivity, callback: ReadRemoteFileCallback) +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApi.kt b/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApi.kt new file mode 100644 index 000000000000..8e11ccd965bb --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApi.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities.data.files + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.BaseActivity + +/** + * Defines an interface to the Files service API. All {[OCFile]} remote data requests + * should be piped through this interface. + */ +interface FilesServiceApi { + interface FilesServiceCallback { + fun onLoaded(ocFile: OCFile) + fun onError(error: String) + } + + fun readRemoteFile(fileUrl: String, activity: BaseActivity, callback: FilesServiceCallback) +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApiImpl.kt b/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApiImpl.kt new file mode 100644 index 000000000000..93236764450c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApiImpl.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities.data.files + +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.ClientFactory.CreationException +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.ui.activities.data.files.FilesServiceApi.FilesServiceCallback +import com.owncloud.android.ui.activity.BaseActivity +import com.owncloud.android.utils.FileStorageUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Implementation of the Files service API that communicates with the NextCloud remote server. + */ +class FilesServiceApiImpl(private val accountManager: UserAccountManager, private val clientFactory: ClientFactory) : + FilesServiceApi { + + override fun readRemoteFile(fileUrl: String, activity: BaseActivity, callback: FilesServiceCallback) { + activity.lifecycleScope.launch(Dispatchers.Main) { + val result = runCatching { + withContext(Dispatchers.IO) { fetchRemoteFile(fileUrl, activity) } + } + + result.fold( + onSuccess = { ocFile -> + if (ocFile != null) { + callback.onLoaded(ocFile) + } else { + callback.onError(activity.getString(R.string.file_not_found)) + } + }, + onFailure = { error -> + val message = when (error) { + is CreationException -> activity.getString(R.string.account_not_found) + else -> error.message + } + Log_OC.e(TAG, "Failed to read remote file", error) + callback.onError(message ?: "") + } + ) + } + } + + private fun fetchRemoteFile(fileUrl: String?, activity: BaseActivity): OCFile? { + val context = MainApp.getAppContext() + val client = clientFactory.create(accountManager.user) + val result = ReadFileRemoteOperation(fileUrl).execute(client) + + if (!result.isSuccess) return null + + val remoteFile = result.getData()[0] as RemoteFile + val ocFile = activity.storageManager.saveFileWithParent( + FileStorageUtils.fillOCFile(remoteFile), + context + ) + + if (ocFile.isFolder) { + RefreshFolderOperation( + ocFile, + System.currentTimeMillis(), + false, + true, + activity.storageManager, + activity.user.orElseThrow { RuntimeException() }, + context + ).execute(client) + } + + return ocFile + } + + companion object { + private val TAG: String = FilesServiceApiImpl::class.java.simpleName + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/files/RemoteFilesRepository.kt b/app/src/main/java/com/owncloud/android/ui/activities/data/files/RemoteFilesRepository.kt new file mode 100644 index 000000000000..4a9aed32e1ab --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/files/RemoteFilesRepository.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Edvard Holst + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activities.data.files + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activities.data.files.FilesRepository.ReadRemoteFileCallback +import com.owncloud.android.ui.activities.data.files.FilesServiceApi.FilesServiceCallback +import com.owncloud.android.ui.activity.BaseActivity + +class RemoteFilesRepository(private val filesServiceApi: FilesServiceApi) : FilesRepository { + override fun readRemoteFile(path: String, activity: BaseActivity, callback: ReadRemoteFileCallback) { + filesServiceApi.readRemoteFile( + path, + activity, + object : FilesServiceCallback { + override fun onLoaded(ocFile: OCFile) { + callback.onFileLoaded(ocFile) + } + + override fun onError(error: String) { + callback.onFileLoadError(error) + } + } + ) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/BaseActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/BaseActivity.java new file mode 100644 index 000000000000..8275a125f60f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/BaseActivity.java @@ -0,0 +1,194 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity; + +import android.accounts.Account; +import android.content.Intent; +import android.os.Bundle; + +import com.nextcloud.android.common.ui.util.extensions.AppCompatActivityExtensionsKt; +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.mixins.MixinRegistry; +import com.nextcloud.client.mixins.SessionMixin; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.client.preferences.DarkMode; +import com.nextcloud.repository.ClientRepository; +import com.nextcloud.repository.RemoteClientRepository; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.status.OCCapability; + +import java.util.Optional; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +/** + * Base activity with common behaviour for activities dealing with ownCloud {@link Account}s . + */ +public abstract class BaseActivity extends AppCompatActivity implements Injectable { + + private static final String TAG = BaseActivity.class.getSimpleName(); + + /** + * Tracks whether the activity should be recreate()'d after a theme change + */ + private boolean themeChangePending; + private boolean paused; + protected boolean enableAccountHandling = true; + + private final MixinRegistry mixinRegistry = new MixinRegistry(); + private SessionMixin sessionMixin; + + @Inject UserAccountManager accountManager; + @Inject AppPreferences preferences; + @Inject FileDataStorageManager fileDataStorageManager; + + private final AppPreferences.Listener onPreferencesChanged = new AppPreferences.Listener() { + @Override + public void onDarkThemeModeChanged(DarkMode mode) { + onThemeSettingsModeChanged(); + } + }; + + public UserAccountManager getUserAccountManager() { + return accountManager; + } + + private ClientRepository clientRepository; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + AppCompatActivityExtensionsKt.applyEdgeToEdgeWithSystemBarPadding(this); + super.onCreate(savedInstanceState); + sessionMixin = new SessionMixin(this, accountManager); + mixinRegistry.add(sessionMixin); + + if (enableAccountHandling) { + mixinRegistry.onCreate(savedInstanceState); + } + + clientRepository = new RemoteClientRepository(accountManager.getUser(), this, this); + } + + @Override + protected void onPostCreate(@Nullable Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + preferences.addListener(onPreferencesChanged); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mixinRegistry.onDestroy(); + preferences.removeListener(onPreferencesChanged); + } + + @Override + protected void onPause() { + super.onPause(); + mixinRegistry.onPause(); + paused = true; + } + + @Override + protected void onResume() { + super.onResume(); + if (enableAccountHandling) { + mixinRegistry.onResume(); + } + paused = false; + + if (themeChangePending) { + recreate(); + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + mixinRegistry.onNewIntent(intent); + } + + @Override + protected void onRestart() { + Log_OC.v(TAG, "onRestart() start"); + super.onRestart(); + if (enableAccountHandling) { + mixinRegistry.onRestart(); + } + } + + private void onThemeSettingsModeChanged() { + if (paused) { + themeChangePending = true; + } else { + recreate(); + } + } + + /** + * Sets and validates the ownCloud {@link Account} associated to the Activity. + * + * If not valid, tries to swap it for other valid and existing ownCloud {@link Account}. + * + * @param account New {@link Account} to set. + * @param savedAccount When 'true', account was retrieved from a saved instance state. + */ + @Deprecated + protected void setAccount(Account account, boolean savedAccount) { + sessionMixin.setAccount(account); + } + + protected void setUser(User user) { + sessionMixin.setUser(user); + } + + protected void startAccountCreation() { + sessionMixin.startAccountCreation(); + } + + /** + * Getter for the capabilities of the server where the current OC account lives. + * + * @return Capabilities of the server where the current OC account lives. Null if the account is not + * set yet. + */ + public Optional getCapabilities() { + return sessionMixin.getCapabilities(); + } + + /** + * Getter for the ownCloud {@link Account} where the main {@link OCFile} handled by the activity + * is located. + * + * @return OwnCloud {@link Account} where the main {@link OCFile} handled by the activity + * is located. + */ + public Account getAccount() { + return sessionMixin.getCurrentAccount(); + } + + public Optional getUser() { + return sessionMixin.getUser(); + } + + public FileDataStorageManager getStorageManager() { + return fileDataStorageManager; + } + + public ClientRepository getClientRepository() { + return clientRepository; + } + +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ComponentsGetter.java b/app/src/main/java/com/owncloud/android/ui/activity/ComponentsGetter.java new file mode 100644 index 000000000000..7ce91df97af4 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ComponentsGetter.java @@ -0,0 +1,42 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-FileCopyrightText: 2012 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import com.nextcloud.client.jobs.download.FileDownloadWorker; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.services.OperationsService.OperationsServiceBinder; +import com.owncloud.android.ui.helpers.FileOperationsHelper; + +public interface ComponentsGetter { + + /** + * To be invoked when the parent activity is fully created to get a reference + * to the FileDownloadWorker. + */ + public FileDownloadWorker.FileDownloadProgressListener getFileDownloadProgressListener(); + + /** + * To be invoked when the parent activity is fully created to get a reference + * to the FileUploader service API. + */ + public FileUploadHelper getFileUploaderHelper(); + + /** + * To be invoked when the parent activity is fully created to get a reference + * to the OperationsService service API. + */ + public OperationsServiceBinder getOperationsServiceBinder(); + + public FileDataStorageManager getStorageManager(); + + public FileOperationsHelper getFileOperationsHelper(); +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt new file mode 100644 index 000000000000..e8ed8b5411dc --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt @@ -0,0 +1,377 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Jonas Mayer + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2019 Alice Gaudon + * SPDX-FileCopyrightText: 2012 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity + +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.FragmentTransaction +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.account.User +import com.nextcloud.client.database.entity.OfflineOperationEntity +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsNotificationManager +import com.nextcloud.client.jobs.operation.FileOperationHelper +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.jobs.upload.UploadNotificationManager +import com.nextcloud.model.HTTPStatusCodes +import com.nextcloud.utils.extensions.getDecryptedPath +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.logFileSize +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.ui.dialog.ConflictsResolveDialog +import com.owncloud.android.ui.dialog.ConflictsResolveDialog.Decision +import com.owncloud.android.ui.dialog.ConflictsResolveDialog.OnConflictDecisionMadeListener +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.FileStorageUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@Suppress("TooManyFunctions", "ReturnCount") +class ConflictsResolveActivity : + FileActivity(), + OnConflictDecisionMadeListener { + + @Inject + lateinit var uploadsStorageManager: UploadsStorageManager + + @Inject + lateinit var fileOperationHelper: FileOperationHelper + + private var conflictUploadId: Long = 0 + private var offlineOperationPath: String? = null + private var existingFile: OCFile? = null + private var newFile: OCFile? = null + private var localBehaviour = FileUploadWorker.LOCAL_BEHAVIOUR_FORGET + private lateinit var offlineOperationNotificationManager: OfflineOperationsNotificationManager + private val uploadHelper = FileUploadHelper.instance() + private val downloadHelper = FileDownloadHelper.instance() + + @JvmField + var listener: OnConflictDecisionMadeListener? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + restoreState(savedInstanceState) + + val upload = uploadsStorageManager.getUploadById(conflictUploadId) + if (upload != null) { + localBehaviour = upload.localAction + } + + newFile = file + setupDecisionListener(upload) + offlineOperationNotificationManager = OfflineOperationsNotificationManager(this, viewThemeUtils) + } + + private fun restoreState(savedInstanceState: Bundle?) { + if (savedInstanceState != null) { + conflictUploadId = savedInstanceState.getLong(EXTRA_CONFLICT_UPLOAD_ID) + existingFile = savedInstanceState.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java) + localBehaviour = savedInstanceState.getInt(EXTRA_LOCAL_BEHAVIOUR) + offlineOperationPath = savedInstanceState.getString(EXTRA_OFFLINE_OPERATION_PATH) + } else { + conflictUploadId = intent.getLongExtra(EXTRA_CONFLICT_UPLOAD_ID, -1) + existingFile = intent.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java) + localBehaviour = intent.getIntExtra(EXTRA_LOCAL_BEHAVIOUR, localBehaviour) + offlineOperationPath = intent.getStringExtra(EXTRA_OFFLINE_OPERATION_PATH) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + existingFile.logFileSize(TAG) + outState.putLong(EXTRA_CONFLICT_UPLOAD_ID, conflictUploadId) + outState.putParcelable(EXTRA_EXISTING_FILE, existingFile) + outState.putInt(EXTRA_LOCAL_BEHAVIOUR, localBehaviour) + outState.putString( + EXTRA_OFFLINE_OPERATION_PATH, + offlineOperationPath + ) + } + + private fun setupDecisionListener(upload: OCUpload?) { + listener = OnConflictDecisionMadeListener { decision -> + val file = newFile + val optionalUser = user + if (optionalUser.isEmpty) { + Log_OC.e(TAG, "user not available") + finish() + return@OnConflictDecisionMadeListener + } + + lifecycleScope.launch(Dispatchers.IO) { + val offlineOperation = offlineOperationPath?.let { + fileDataStorageManager.offlineOperationDao.getByPath(it) + } + + when (decision) { + Decision.KEEP_LOCAL -> handleFile(file, upload, optionalUser.get(), NameCollisionPolicy.OVERWRITE) + Decision.KEEP_BOTH -> handleFile(file, upload, optionalUser.get(), NameCollisionPolicy.RENAME) + Decision.KEEP_SERVER -> keepServer(file, upload, optionalUser.get()) + Decision.KEEP_OFFLINE_FOLDER -> keepOfflineFolder(file, offlineOperation) + Decision.KEEP_SERVER_FOLDER -> keepServerFile(offlineOperation) + Decision.KEEP_BOTH_FOLDER -> keepBothFolder(offlineOperation, file) + else -> Unit + } + + upload?.remotePath?.let { path -> + val oldFile = storageManager.getFileByDecryptedRemotePath(path) + updateThumbnailIfNeeded(decision, file, oldFile) + } + + withContext(Dispatchers.Main) { + dismissConflictResolveNotification() + finish() + } + } + } + } + + private fun handleFile(file: OCFile?, upload: OCUpload?, user: User, policy: NameCollisionPolicy) { + upload?.let { uploadHelper.removeFileUpload(it.remotePath, it.accountName) } + uploadHelper.uploadUpdatedFile( + user, + arrayOf(file), + localBehaviour, + policy, + skipAutoUploadCheck = true + ) + } + + private suspend fun keepServer(file: OCFile?, upload: OCUpload?, user: User) { + if (!shouldDeleteLocal()) { + file?.let { + downloadHelper.downloadFile( + user, + file, + conflictUploadId = conflictUploadId + ) + } + } + + upload?.let { + uploadHelper.removeFileUpload(it.remotePath, it.accountName) + val id = it.uploadId.toInt() + + withContext(Dispatchers.Main) { + UploadNotificationManager(applicationContext, viewThemeUtils, id).dismissNotification(id) + } + } + } + + private suspend fun keepBothFolder(offlineOperation: OfflineOperationEntity?, serverFile: OCFile?) { + offlineOperation ?: return + fileDataStorageManager.keepOfflineOperationAndServerFile(offlineOperation, serverFile) + backgroundJobManager.startOfflineOperations() + withContext(Dispatchers.Main) { + offlineOperationNotificationManager.dismissNotification(offlineOperation.id) + } + } + + private suspend fun keepServerFile(offlineOperation: OfflineOperationEntity?) { + offlineOperation ?: return + fileDataStorageManager.offlineOperationDao.delete(offlineOperation) + offlineOperation.id?.let { + withContext(Dispatchers.Main) { + offlineOperationNotificationManager.dismissNotification(it) + } + } + } + + private suspend fun keepOfflineFolder(serverFile: OCFile?, offlineOperation: OfflineOperationEntity?) { + serverFile ?: return + offlineOperation ?: return + + val client = clientRepository.getOwncloudClient() ?: return + val isSuccess = fileOperationHelper.removeFile( + serverFile, + onlyLocalCopy = false, + inBackground = false, + client = client + ) + + if (isSuccess) { + backgroundJobManager.startOfflineOperations() + withContext(Dispatchers.Main) { + offlineOperationNotificationManager.dismissNotification(offlineOperation.id) + } + } + } + + @Suppress("ReturnCount") + override fun onStart() { + super.onStart() + + if (account == null) { + finish() + return + } + if (newFile == null) { + Log_OC.e(TAG, "No file received") + finish() + return + } + + offlineOperationPath?.let { path -> + showOfflineOperationConflictDialog(path) + return + } + + if (existingFile == null) { + fetchRemoteFileAndShowDialog() + } else { + val remotePath = fileDataStorageManager.retrieveRemotePathConsideringEncryption(existingFile) ?: return + showFileConflictDialog(remotePath) + } + } + + private fun showOfflineOperationConflictDialog(path: String) { + val offlineOperation = fileDataStorageManager.offlineOperationDao.getByPath(path) + if (offlineOperation == null) { + showErrorAndFinish() + return + } + + val (ft, _) = prepareDialogTransaction() + ConflictsResolveDialog.newInstance( + context = this, + leftFile = offlineOperation, + rightFile = newFile!! + ).show(ft, "conflictDialog") + } + + @Suppress("TooGenericExceptionCaught", "DEPRECATION") + private fun fetchRemoteFileAndShowDialog() { + val remotePath = fileDataStorageManager.retrieveRemotePathConsideringEncryption(newFile) ?: return + val operation = ReadFileRemoteOperation(remotePath) + + lifecycleScope.launch(Dispatchers.IO) { + try { + val result = operation.execute(account, this@ConflictsResolveActivity) + if (result.isSuccess) { + existingFile = FileStorageUtils.fillOCFile(result.data[0] as RemoteFile).also { + it.lastSyncDateForProperties = System.currentTimeMillis() + } + showFileConflictDialog(remotePath) + } else { + Log_OC.e(TAG, "ReadFileRemoteOp returned failure with code: ${result.httpCode}") + showErrorAndFinish(result.httpCode) + } + } catch (e: Exception) { + Log_OC.e(TAG, "Error when trying to fetch remote file", e) + showErrorAndFinish() + } + } + } + + private fun showFileConflictDialog(remotePath: String) { + val (ft, user) = prepareDialogTransaction() + if (existingFile != null && storageManager.fileExists(remotePath) && newFile != null) { + ConflictsResolveDialog.newInstance( + title = storageManager.getDecryptedPath(existingFile!!), + context = this, + leftFile = newFile!!, + rightFile = existingFile!!, + user = user + ).show(ft, "conflictDialog") + } else { + Log_OC.e(TAG, "Account was changed, finishing") + showErrorAndFinish() + } + } + + @SuppressLint("CommitTransaction") + private fun prepareDialogTransaction(): Pair { + val userOptional = user + if (!userOptional.isPresent) { + Log_OC.e(TAG, "User not present") + showErrorAndFinish() + } + + val fragmentTransaction = supportFragmentManager.beginTransaction() + supportFragmentManager.findFragmentByTag("conflictDialog")?.let { + fragmentTransaction.remove(it) + } + + return fragmentTransaction to user.get() + } + + override fun conflictDecisionMade(decision: Decision?) { + listener?.conflictDecisionMade(decision) + } + + private fun updateThumbnailIfNeeded(decision: Decision?, file: OCFile?, oldFile: OCFile?) { + if (decision != Decision.KEEP_BOTH && decision != Decision.KEEP_LOCAL) return + + if (decision == Decision.KEEP_LOCAL) { + ThumbnailsCacheManager.removeFromCache(oldFile) + } + + file?.isUpdateThumbnailNeeded = true + fileDataStorageManager.saveFile(file) + } + + private fun dismissConflictResolveNotification() { + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).cancel(conflictUploadId.toInt()) + } + + private fun showErrorAndFinish(code: Int? = null) { + val message = if (code == HTTPStatusCodes.NOT_FOUND.code) { + getString(R.string.uploader_file_not_found_on_server_message) + } else { + getString(R.string.conflict_dialog_error) + } + + lifecycleScope.launch(Dispatchers.Main) { + DisplayUtils.showSnackMessage(this@ConflictsResolveActivity, message) + finish() + } + } + + private fun shouldDeleteLocal(): Boolean = localBehaviour == FileUploadWorker.LOCAL_BEHAVIOUR_DELETE + + companion object { + const val EXTRA_CONFLICT_UPLOAD_ID = "CONFLICT_UPLOAD_ID" + const val EXTRA_LOCAL_BEHAVIOUR = "LOCAL_BEHAVIOUR" + const val EXTRA_EXISTING_FILE = "EXISTING_FILE" + private const val EXTRA_OFFLINE_OPERATION_PATH = "EXTRA_OFFLINE_OPERATION_PATH" + private val TAG = ConflictsResolveActivity::class.java.simpleName + + @JvmStatic + fun createIntent(file: OCFile?, user: User?, conflictUploadId: Long, flag: Int?, context: Context?): Intent = + Intent(context, ConflictsResolveActivity::class.java).apply { + if (flag != null) flags = flags or flag + putExtra(EXTRA_FILE, file) + putExtra(EXTRA_USER, user) + putExtra(EXTRA_CONFLICT_UPLOAD_ID, conflictUploadId) + } + + @JvmStatic + fun createIntent(file: OCFile, offlineOperationPath: String, context: Context): Intent = + Intent(context, ConflictsResolveActivity::class.java).apply { + putExtra(EXTRA_FILE, file) + putExtra(EXTRA_OFFLINE_OPERATION_PATH, offlineOperationPath) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java new file mode 100644 index 000000000000..0811c5dbfbad --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java @@ -0,0 +1,141 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.jobs.BackgroundJobManager; +import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.ui.fragment.FileFragment; +import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment; +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment; + +import javax.inject.Inject; + +import androidx.activity.OnBackPressedCallback; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; + +/** + * This activity shows all settings for contact backup/restore + */ +public class ContactsPreferenceActivity extends FileActivity implements FileFragment.ContainerActivity { + public static final String TAG = ContactsPreferenceActivity.class.getSimpleName(); + public static final String EXTRA_FILE = "FILE"; + public static final String EXTRA_USER = "USER"; + /** + * Warning: default for this extra is different between this activity and {@link BackupFragment} + */ + public static final String EXTRA_SHOW_SIDEBAR = "SHOW_SIDEBAR"; + public static final String PREFERENCE_CONTACTS_AUTOMATIC_BACKUP = "PREFERENCE_CONTACTS_AUTOMATIC_BACKUP"; + public static final String PREFERENCE_CONTACTS_LAST_BACKUP = "PREFERENCE_CONTACTS_LAST_BACKUP"; + public static final String BACKUP_TO_LIST = "BACKUP_TO_LIST"; + + @Inject BackgroundJobManager backgroundJobManager; + + public static void startActivity(Context context) { + Intent intent = new Intent(context, ContactsPreferenceActivity.class); + context.startActivity(intent); + } + + public static void startActivityWithContactsFile(Context context, User user, OCFile file) { + Intent intent = new Intent(context, ContactsPreferenceActivity.class); + intent.putExtra(EXTRA_FILE, file); + intent.putExtra(EXTRA_USER, user); + context.startActivity(intent); + } + + public static void startActivityWithoutSidebar(Context context) { + Intent intent = new Intent(context, ContactsPreferenceActivity.class); + intent.putExtra(EXTRA_SHOW_SIDEBAR, false); + context.startActivity(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.contacts_preference); + + // setup toolbar + setupToolbar(); + + // setup drawer + //setupDrawer(R.id.nav_contacts); // TODO needed? + + // show sidebar? + boolean showSidebar = getIntent().getBooleanExtra(EXTRA_SHOW_SIDEBAR, true); + if (!showSidebar) { + setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); + + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + if (mDrawerToggle != null) { + mDrawerToggle.setDrawerIndicatorEnabled(false); + } + } + + Intent intent = getIntent(); + if (savedInstanceState == null) { + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + if (intent == null || + IntentExtensionsKt.getParcelableArgument(intent, EXTRA_FILE, OCFile.class) == null || + IntentExtensionsKt.getParcelableArgument(intent, EXTRA_USER, User.class) == null) { + BackupFragment fragment = BackupFragment.create(showSidebar); + transaction.add(R.id.frame_container, fragment); + } else { + OCFile file = IntentExtensionsKt.getParcelableArgument(intent, EXTRA_FILE, OCFile.class); + User user = IntentExtensionsKt.getParcelableArgument(intent, EXTRA_USER, User.class); + BackupListFragment contactListFragment = BackupListFragment.newInstance(file, user); + transaction.add(R.id.frame_container, contactListFragment); + } + transaction.commit(); + } + + getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); + } + + @Override + public void showDetails(OCFile file) { + // not needed + } + + @Override + public void showDetails(OCFile file, int activeTab) { + // not needed + } + + @Override + public void onBrowsedDownTo(OCFile folder) { + // not needed + } + + @Override + public void onTransferStateChanged(OCFile file, boolean downloading, boolean uploading) { + // not needed + } + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (getSupportFragmentManager().findFragmentByTag(BackupListFragment.TAG) != null) { + getSupportFragmentManager().popBackStack(BACKUP_TO_LIST, FragmentManager.POP_BACK_STACK_INCLUSIVE); + } else { + finish(); + } + } + }; +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/CopyToClipboardActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/CopyToClipboardActivity.kt new file mode 100644 index 000000000000..2811b200791a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/CopyToClipboardActivity.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.owncloud.android.utils.ClipboardUtil + +/** + * Activity copying the text of the received Intent into the system clipboard. + */ +class CopyToClipboardActivity : Activity() { + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ClipboardUtil.copyToClipboard(this, intent.getCharSequenceExtra(Intent.EXTRA_TEXT).toString()) + finish() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java new file mode 100644 index 000000000000..b63241173040 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -0,0 +1,1496 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021-2024 TSI-mc + * SPDX-FileCopyrightText: 2020 Infomaniak Network SA + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 Nextcloud + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.LayerDrawable; +import android.graphics.drawable.PictureDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.PersistableBundle; +import android.os.SystemClock; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.webkit.URLUtil; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.request.transition.Transition; +import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.navigation.NavigationView; +import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.nextcloud.android.common.core.utils.ecosystem.EcosystemApp; +import com.nextcloud.android.common.core.utils.ecosystem.EcosystemManager; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.nextcloud.client.account.User; +import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.files.DeepLinkConstants; +import com.nextcloud.client.network.ClientFactory; +import com.nextcloud.client.onboarding.FirstRunActivity; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.common.NextcloudClient; +import com.nextcloud.ui.ChooseAccountDialogFragment; +import com.nextcloud.ui.composeActivity.ComposeActivity; +import com.nextcloud.ui.composeActivity.ComposeDestination; +import com.nextcloud.utils.GlideHelper; +import com.nextcloud.utils.LinkHelper; +import com.nextcloud.utils.extensions.ActivityExtensionsKt; +import com.nextcloud.utils.extensions.DrawerActivityExtensionsKt; +import com.nextcloud.utils.extensions.NavigationViewExtensionsKt; +import com.nextcloud.utils.extensions.ViewExtensionsKt; +import com.nextcloud.utils.mdm.MDMConfig; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.authentication.PassCodeManager; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.ExternalLinksProvider; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.ExternalLink; +import com.owncloud.android.lib.common.ExternalLinkType; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.Quota; +import com.owncloud.android.lib.common.UserInfo; +import com.owncloud.android.lib.common.accounts.ExternalLinksOperation; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.SearchRemoteOperation; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.lib.resources.users.GetUserInfoRemoteOperation; +import com.owncloud.android.operations.GetCapabilitiesOperation; +import com.owncloud.android.ui.events.AccountRemovedEvent; +import com.owncloud.android.ui.events.ChangeMenuEvent; +import com.owncloud.android.ui.events.SearchEvent; +import com.owncloud.android.ui.fragment.FileDetailsSharingProcessFragment; +import com.owncloud.android.ui.fragment.OCFileListFragment; +import com.owncloud.android.ui.navigation.NavigatorActivity; +import com.owncloud.android.ui.navigation.NavigatorScreen; +import com.owncloud.android.ui.trashbin.TrashbinActivity; +import com.owncloud.android.utils.BitmapUtils; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.DrawableUtil; +import com.owncloud.android.utils.DrawerMenuUtil; +import com.owncloud.android.utils.FilesSyncHelper; +import com.owncloud.android.utils.theme.CapabilityUtils; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBarDrawerToggle; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.content.ContextCompat; +import androidx.core.content.res.ResourcesCompat; +import androidx.core.view.GravityCompat; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.fragment.app.Fragment; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hct.Hct; +import kotlin.Unit; + +/** + * Base class to handle setup of the drawer implementation including user switching and avatar fetching and fallback + * generation. + */ +public abstract class DrawerActivity extends ToolbarActivity + implements DisplayUtils.AvatarGenerationListener, Injectable { + + private static final String TAG = DrawerActivity.class.getSimpleName(); + private static final String KEY_IS_ACCOUNT_CHOOSER_ACTIVE = "IS_ACCOUNT_CHOOSER_ACTIVE"; + private static final int ACTION_MANAGE_ACCOUNTS = 101; + private static final int MENU_ORDER_EXTERNAL_LINKS = 3; + private static final int MENU_ITEM_EXTERNAL_LINK = 111; + private static final int MAX_LOGO_SIZE_PX = 1000; + private static final int RELATIVE_THRESHOLD_WARNING = 80; + public static final int REQ_ALL_FILES_ACCESS = 3001; + public static final int REQ_MEDIA_ACCESS = 3000; + + /** + * Reference to the drawer layout. + */ + private DrawerLayout mDrawerLayout; + + /** + * Reference to the drawer toggle. + */ + protected ActionBarDrawerToggle mDrawerToggle; + + /** + * Reference to the navigation view header. + */ + private View mNavigationViewHeader; + + /** + * Flag to signal if the account chooser is active. + */ + private boolean mIsAccountChooserActive; + + /** + * container layout of the quota view. + */ + private LinearLayout mQuotaView; + + /** + * progress bar of the quota view. + */ + private LinearProgressIndicator mQuotaProgressBar; + + /** + * text view of the quota view. + */ + private TextView mQuotaTextPercentage; + private TextView mQuotaTextLink; + + /** + * runnable that will be executed after the drawer has been closed. + */ + private Runnable pendingRunnable; + + private ExternalLinksProvider externalLinksProvider; + private ArbitraryDataProvider arbitraryDataProvider; + + private BottomNavigationView bottomNavigationView; + private NavigationView drawerNavigationView; + + /** + * Returns the navigation drawer menu item ID that represents + * the current activity. + * + *

+ * This method is used by the DrawerActivity to determine + * which drawer item should be highlighted (checked) when the + * activity is visible. + *

+ * + *

+ * Subclasses that are displayed within the drawer must override + * this method and return their corresponding menu item ID + * (e.g. R.id.nav_gallery, R.id.nav_settings). + *

+ * + *

+ * The default implementation returns {@link R.id#nav_all_files}. + *

+ * + * @return the menu item ID to be marked as selected in the drawer + */ + protected int getMenuItemId() { + return R.id.nav_all_files; + } + + private EcosystemManager ecosystemManager; + + @Inject + AppPreferences preferences; + + @Inject + protected ClientFactory clientFactory; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState, @Nullable PersistableBundle persistentState) { + super.onCreate(savedInstanceState, persistentState); + addOnBackPressedCallback(); + } + + /** + * Initializes the drawer and its content. This method needs to be called after the content view has been set. + */ + protected void setupDrawer(int id) { + if (mDrawerLayout == null) { + mDrawerLayout = findViewById(R.id.drawer_layout); + } + + if (drawerNavigationView == null) { + drawerNavigationView = findViewById(R.id.nav_view); + } + + if (drawerNavigationView != null) { + viewThemeUtils.files.colorNavigationView(drawerNavigationView); + + // Setting up drawer header + mNavigationViewHeader = drawerNavigationView.getHeaderView(0); + updateHeader(); + + setupDrawerMenu(drawerNavigationView); + getAndDisplayUserQuota(); + setupQuotaElement(); + highlightNavigationViewItem(id); + } + + setupDrawerToggle(); + + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + if (bottomNavigationView == null) { + bottomNavigationView = findViewById(R.id.bottom_navigation); + } + + if (bottomNavigationView != null) { + themeBottomNavigationMenu(); + checkAssistantBottomNavigationMenu(); + handleBottomNavigationViewClicks(); + highlightNavigationViewItem(id); + } + } + + /** + * Highlights (checks) the given menu item ID in the app's navigation bars. + * + *

+ * This method updates both the navigation drawer (`drawerNavigationView`) and + * the bottom navigation bar (`bottomNavigationView`). + *

+ * + *

+ * This method is needs to be called from onResume() of child activities with all possible menu item ids. + * This fixes: + *

    + *
  • When navigating back from another activity (e.g., Activity B → Activity A), + * the previously selected menu item remains highlighted.
  • + *
+ *

+ * + * @param menuItemId the ID of the menu item to mark as selected/highlighted + */ + public void highlightNavigationViewItem(int menuItemId) { + NavigationViewExtensionsKt.highlightNavigationView(drawerNavigationView, + bottomNavigationView, + menuItemId); + Log_OC.d(TAG, "New menu item is: " + menuItemId); + } + + private void themeBottomNavigationMenu() { + viewThemeUtils.platform.colorBottomNavigationView(bottomNavigationView); + } + + @SuppressFBWarnings("RV") + private void checkAssistantBottomNavigationMenu() { + final var optionalCapabilities = getCapabilities(); + boolean isAssistantAvailable = false; + if (optionalCapabilities.isPresent()) { + isAssistantAvailable = optionalCapabilities.get().getAssistant().isTrue(); + } + + bottomNavigationView + .getMenu() + .findItem(R.id.nav_assistant) + .setVisible(isAssistantAvailable); + } + + private void openFavoritesTab() { + resetOnlyPersonalAndOnDevice(); + setupToolbar(); + SearchEvent searchEvent = new SearchEvent("", SearchRemoteOperation.SearchType.FAVORITE_SEARCH); + launchActivityForSearch(searchEvent, R.id.nav_favorites); + } + + private void openMediaTab(int menuItemId) { + resetOnlyPersonalAndOnDevice(); + setupToolbar(); + startPhotoSearch(menuItemId); + } + + @Nullable + public OCFileListFragment getOCFileListFragment() { + Fragment fragment = ActivityExtensionsKt.lastFragment(this); + if (fragment instanceof OCFileListFragment fileListFragment) { + return fileListFragment; + } + + fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES); + if (fragment instanceof OCFileListFragment fileListFragment) { + return fileListFragment; + } + + return null; + } + + private void exitSelectionMode() { + Fragment fragment = getOCFileListFragment(); + if (fragment instanceof OCFileListFragment fileListFragment) { + fileListFragment.exitSelectionMode(); + } + } + + private void setupDrawerToggle() { + mDrawerToggle = new ActionBarDrawerToggle(this, mDrawerLayout, R.string.drawer_open, R.string.drawer_close) { + public void onDrawerClosed(View view) { + super.onDrawerClosed(view); + + if (pendingRunnable != null) { + new Handler().post(pendingRunnable); + pendingRunnable = null; + } + + closeDrawer(); + } + }; + + mDrawerLayout.addDrawerListener(mDrawerToggle); + mDrawerToggle.setDrawerIndicatorEnabled(true); + mDrawerToggle.setDrawerSlideAnimationEnabled(true); + final Drawable backArrow = ResourcesCompat.getDrawable(getResources(), R.drawable.ic_arrow_back, null); + if (backArrow != null) { + viewThemeUtils.platform.tintToolbarArrowDrawable(this, mDrawerToggle, backArrow); + } + } + + /** + * setup quota elements of the drawer. + */ + private void setupQuotaElement() { + mQuotaView = (LinearLayout) findQuotaViewById(R.id.drawer_quota); + mQuotaProgressBar = (LinearProgressIndicator) findQuotaViewById(R.id.drawer_quota_ProgressBar); + mQuotaTextPercentage = (TextView) findQuotaViewById(R.id.drawer_quota_percentage); + mQuotaTextLink = (TextView) findQuotaViewById(R.id.drawer_quota_link); + viewThemeUtils.material.colorProgressBar(mQuotaProgressBar, ColorRole.PRIMARY); + mQuotaProgressBar.setTrackStopIndicatorSize(0); + viewThemeUtils.platform.colorViewBackground(mQuotaView); + } + + public void updateHeader() { + final var account = getAccount(); + boolean isClientBranded = getResources().getBoolean(R.bool.is_branded_client); + final var optionalCapability = getCapabilities(); + if (optionalCapability.isPresent()) { + final var capability = optionalCapability.get(); + if (account != null && capability.getServerBackground() != null && !isClientBranded) { + int primaryColor = themeColorUtils.unchangedPrimaryColor(account, this); + String serverLogoURL = capability.getServerLogo(); + + // set background to primary color + LinearLayout drawerHeader = mNavigationViewHeader.findViewById(R.id.drawer_header_view); + drawerHeader.setBackgroundColor(primaryColor); + + if (!TextUtils.isEmpty(serverLogoURL) && URLUtil.isValidUrl(serverLogoURL)) { + Target target = createSVGLogoTarget(primaryColor, capability); + GlideHelper.INSTANCE.loadIntoTarget(this, + accountManager.getCurrentOwnCloudAccount(), + serverLogoURL, + target, + R.drawable.background); + } + } + } + + // hide ecosystem apps according to user preference or in branded client + ConstraintLayout banner = mNavigationViewHeader.findViewById(R.id.drawer_ecosystem_apps); + boolean shouldHideTopBanner = isClientBranded || !preferences.isShowEcosystemApps(); + + if (shouldHideTopBanner) { + hideTopBanner(banner); + } else { + showTopBanner(banner); + } + } + + private Target createSVGLogoTarget(int primaryColor, OCCapability capability) { + return new CustomTarget<>() { + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + Bitmap bitmap; + + if (resource instanceof PictureDrawable pictureDrawable) { + bitmap = Bitmap.createBitmap( + pictureDrawable.getIntrinsicWidth(), + pictureDrawable.getIntrinsicHeight(), + Bitmap.Config.ARGB_8888); + + Canvas canvas = new Canvas(bitmap); + canvas.drawPicture(pictureDrawable.getPicture()); + + } else if (resource instanceof BitmapDrawable bitmapDrawable) { + bitmap = bitmapDrawable.getBitmap(); + } else { + Log_OC.e(TAG, "Unsupported drawable type: " + resource.getClass().getName()); + return; + } + + // Scale down if necessary + Bitmap logo = bitmap; + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + int max = Math.max(width, height); + if (max > MAX_LOGO_SIZE_PX) { + logo = BitmapUtils.scaleBitmap(bitmap, MAX_LOGO_SIZE_PX, width, height, max); + } + + Drawable[] drawables = { + new ColorDrawable(primaryColor), + new BitmapDrawable(getResources(), logo) + }; + LayerDrawable layerDrawable = new LayerDrawable(drawables); + + String name = capability.getServerName(); + setDrawerHeaderLogo(layerDrawable, name); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) {} + }; + } + + private void hideTopBanner(ConstraintLayout banner) { + banner.setVisibility(View.GONE); + } + + private void showTopBanner(ConstraintLayout banner) { + LinearLayout notesView = banner.findViewById(R.id.drawer_ecosystem_notes); + LinearLayout talkView = banner.findViewById(R.id.drawer_ecosystem_talk); + LinearLayout moreView = banner.findViewById(R.id.drawer_ecosystem_more); + LinearLayout assistantView = banner.findViewById(R.id.drawer_ecosystem_assistant); + + final var optionalUser = getUser(); + if (optionalUser.isPresent()) { + final var accountName = optionalUser.get().getAccountName(); + notesView.setOnClickListener(v -> ecosystemManager.openApp(EcosystemApp.NOTES, accountName)); + talkView.setOnClickListener(v -> ecosystemManager.openApp(EcosystemApp.TALK, accountName)); + } + + moreView.setOnClickListener(v -> LinkHelper.INSTANCE.openAppStore("Nextcloud", true, this)); + assistantView.setOnClickListener(v -> startAssistantScreen()); + final var optionalCapabilities = getCapabilities(); + if (optionalCapabilities.isPresent()) { + final var capabilities = optionalCapabilities.get(); + if (capabilities.getAssistant().isTrue()) { + assistantView.setVisibility(View.VISIBLE); + } else { + assistantView.setVisibility(View.GONE); + } + } else { + assistantView.setVisibility(View.GONE); + } + + List views = Arrays.asList(notesView, talkView, moreView, assistantView); + + int iconColor; + final var account = getAccount(); + if (account != null) { + int primaryColor = themeColorUtils.unchangedPrimaryColor(account, this); + if (Hct.fromInt(primaryColor).getTone() < 80.0) { + iconColor = Color.WHITE; + } else { + iconColor = getColor(R.color.grey_800_transparent); + } + } else { + iconColor = getColor(R.color.grey_800_transparent); + } + + for (LinearLayout view : views) { + ImageView imageView = (ImageView) view.getChildAt(0); + imageView.setImageTintList(ColorStateList.valueOf(iconColor)); + GradientDrawable background = (GradientDrawable) imageView.getBackground(); + background.setStroke(DisplayUtils.convertDpToPixel(1, this), iconColor); + TextView textView = (TextView) view.getChildAt(1); + textView.setTextColor(iconColor); + } + + banner.setVisibility(View.VISIBLE); + } + + private void setDrawerHeaderLogo(Drawable drawable, String serverName) { + ImageView imageHeader = mNavigationViewHeader.findViewById(R.id.drawer_header_logo); + imageHeader.setImageDrawable(drawable); + imageHeader.setAdjustViewBounds(true); + + if (!TextUtils.isEmpty(serverName)) { + TextView serverNameView = mNavigationViewHeader.findViewById(R.id.drawer_header_server_name); + serverNameView.setVisibility(View.VISIBLE); + serverNameView.setText(serverName); + serverNameView.setTextColor(themeColorUtils.unchangedFontColor(this)); + } + + } + + /** + * setup drawer content, basically setting the item selected listener. + * + * @param navigationView the drawers navigation view + */ + private void setupDrawerMenu(NavigationView navigationView) { + + // setup actions for drawer menu items + navigationView.setNavigationItemSelectedListener( + menuItem -> { + mDrawerLayout.closeDrawers(); + // pending runnable will be executed after the drawer has been closed + pendingRunnable = () -> onNavigationItemClicked(menuItem); + return true; + }); + + User account = accountManager.getUser(); + filterDrawerMenu(navigationView.getMenu(), account); + } + + private void filterDrawerMenu(final Menu menu, @NonNull final User user) { + final var optionalCapability = getCapabilities(); + if (optionalCapability.isPresent()) { + final var capability = optionalCapability.get(); + DrawerMenuUtil.filterTrashbinMenuItem(menu, capability); + DrawerMenuUtil.filterActivityMenuItem(menu, capability); + DrawerMenuUtil.filterGroupfoldersMenuItem(menu, capability); + DrawerMenuUtil.filterAssistantMenuItem(menu, capability, getResources()); + } + + DrawerMenuUtil.filterSearchMenuItems(menu, user); + DrawerMenuUtil.setupHomeMenuItem(menu, getResources()); + DrawerMenuUtil.removeMenuItem(menu, R.id.nav_community, !getResources().getBoolean(R.bool.participate_enabled)); + DrawerMenuUtil.removeMenuItem(menu, R.id.nav_shared, !getResources().getBoolean(R.bool.shared_enabled)); + DrawerMenuUtil.removeMenuItem(menu, R.id.nav_logout, !getResources().getBoolean(R.bool.show_drawer_logout)); + } + + // region navigation item click + private void onNavigationItemClicked(final MenuItem menuItem) { + int itemId = menuItem.getItemId(); + + if (itemId == R.id.nav_all_files || itemId == R.id.nav_personal_files) { + closeDrawer(); + DrawerActivityExtensionsKt.navigateToAllFiles(this,itemId == R.id.nav_personal_files); + EventBus.getDefault().post(new ChangeMenuEvent()); + } else if (itemId == R.id.nav_favorites) { + openFavoritesTab(); + } else if (itemId == R.id.nav_gallery) { + openMediaTab(menuItem.getItemId()); + } else if (itemId == R.id.nav_on_device) { + showOnDeviceFiles(); + } else if (itemId == R.id.nav_uploads) { + resetOnlyPersonalAndOnDevice(); + startActivity(UploadListActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP); + } else if (itemId == R.id.nav_trashbin) { + resetOnlyPersonalAndOnDevice(); + startActivity(TrashbinActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP); + } else if (itemId == R.id.nav_activity) { + resetOnlyPersonalAndOnDevice(); + pushFragment(NavigatorScreen.Activities.INSTANCE); + } else if (itemId == R.id.nav_settings) { + resetOnlyPersonalAndOnDevice(); + final Intent intent = new Intent(this, SettingsActivity.class); + startActivity(intent); + } else if (itemId == R.id.nav_community) { + resetOnlyPersonalAndOnDevice(); + pushFragment(NavigatorScreen.Community.INSTANCE); + } else if (itemId == R.id.nav_logout) { + resetOnlyPersonalAndOnDevice(); + MenuItem isNewMenuItemChecked = menuItem.setChecked(false); + Log_OC.d(TAG,"onNavigationItemClicked nav_logout setChecked " + isNewMenuItemChecked); + final Optional optionalUser = getUser(); + if (optionalUser.isPresent()) { + UserInfoActivity.openAccountRemovalDialog(optionalUser.get(), getSupportFragmentManager()); + } + } else if (itemId == R.id.nav_shared) { + openSharedTab(); + } else if (itemId == R.id.nav_recent_files) { + resetOnlyPersonalAndOnDevice(); + startRecentlyModifiedSearch(menuItem); + } else if (itemId == R.id.nav_assistant) { + resetOnlyPersonalAndOnDevice(); + startAssistantScreen(); + } else if (itemId == R.id.nav_groupfolders) { + resetOnlyPersonalAndOnDevice(); + Intent intent = new Intent(getApplicationContext(), FileDisplayActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setAction(FileDisplayActivity.LIST_GROUPFOLDERS); + startActivity(intent); + } else { + if (menuItem.getItemId() >= MENU_ITEM_EXTERNAL_LINK && + menuItem.getItemId() <= MENU_ITEM_EXTERNAL_LINK + 100) { + // external link clicked + externalLinkClicked(menuItem); + } else { + Log_OC.w(TAG, "Unknown drawer menu item clicked: " + menuItem.getTitle()); + } + } + + // from navigation user always sees root level + resetFileDepth(); + + highlightNavigationViewItem(itemId); + } + + /** + * If navigator activity already exists just push else start navigator activity. + */ + public void pushFragment(NavigatorScreen screen) { + if (this instanceof NavigatorActivity navigatorActivity) { + navigatorActivity.push(screen); + } else { + final var intent = NavigatorActivity.Companion.intent(this, screen); + startActivity(intent); + } + } + + @SuppressFBWarnings("RV") + private void handleBottomNavigationViewClicks() { + bottomNavigationView.setOnItemSelectedListener(menuItem -> { + int menuItemId = menuItem.getItemId(); + exitSelectionMode(); + resetOnlyPersonalAndOnDevice(); + + if (menuItemId == R.id.nav_all_files) { + DrawerActivityExtensionsKt.navigateToAllFiles(this); + EventBus.getDefault().post(new ChangeMenuEvent()); + } else if (menuItemId == R.id.nav_favorites) { + openFavoritesTab(); + } else if (menuItemId == R.id.nav_assistant && !(this instanceof ComposeActivity)) { + startAssistantScreen(); + } else if (menuItemId == R.id.nav_gallery) { + openMediaTab(menuItem.getItemId()); + } + + // Remove extra icon from the action bar + if (getSupportActionBar() != null) { + getSupportActionBar().setIcon(null); + } + + // from navigation user always sees root level + resetFileDepth(); + + highlightNavigationViewItem(menuItemId); + return false; + }); + } + // endregion + + private void startAssistantScreen() { + final var destination = ComposeDestination.Companion.getAssistantScreen(this); + Intent composeActivity = new Intent(getApplicationContext(), ComposeActivity.class); + final Bundle bundle = new Bundle(); + bundle.putParcelable(ComposeActivity.DESTINATION, destination); + composeActivity.putExtras(bundle); + startActivity(composeActivity); + } + + void startActivity(Class activity) { + startActivity(new Intent(getApplicationContext(), activity)); + } + + private void startActivity(Class activity, int flags) { + Intent intent = new Intent(getApplicationContext(), activity); + intent.setFlags(flags); + startActivity(intent); + } + + public void showManageAccountsDialog() { + ChooseAccountDialogFragment choseAccountDialog = ChooseAccountDialogFragment.newInstance(accountManager.getUser()); + choseAccountDialog.show(getSupportFragmentManager(), "fragment_chose_account"); + } + + public void openManageAccounts() { + Intent manageAccountsIntent = new Intent(getApplicationContext(), ManageAccountsActivity.class); + startActivityForResult(manageAccountsIntent, ACTION_MANAGE_ACCOUNTS); + } + + public void openAddAccount() { + if (MDMConfig.INSTANCE.showIntro(this)) { + Intent firstRunIntent = new Intent(getApplicationContext(), FirstRunActivity.class); + firstRunIntent.putExtra(FirstRunActivity.EXTRA_ALLOW_CLOSE, true); + startActivity(firstRunIntent); + } else { + startAccountCreation(); + } + } + + private void resetFileDepth() { + final var ocFileListFragment = getOCFileListFragment(); + if (ocFileListFragment != null) { + ocFileListFragment.resetFileDepth(); + } + } + + private void openSharedTab() { + resetOnlyPersonalAndOnDevice(); + SearchEvent searchEvent = new SearchEvent("", SearchRemoteOperation.SearchType.SHARED_FILTER); + launchActivityForSearch(searchEvent, R.id.nav_shared); + } + + private void startRecentlyModifiedSearch(MenuItem menuItem) { + SearchEvent searchEvent = new SearchEvent("", SearchRemoteOperation.SearchType.RECENTLY_MODIFIED_SEARCH); + MainApp.showOnlyFilesOnDevice(false); + + launchActivityForSearch(searchEvent, menuItem.getItemId()); + } + + public void startPhotoSearch(int id) { + SearchEvent searchEvent = new SearchEvent("image/%", SearchRemoteOperation.SearchType.PHOTO_SEARCH); + MainApp.showOnlyFilesOnDevice(false); + + launchActivityForSearch(searchEvent, id); + } + + private void launchActivityForSearch(SearchEvent searchEvent, int menuItemId) { + Intent intent = new Intent(getApplicationContext(), FileDisplayActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setAction(Intent.ACTION_SEARCH); + intent.putExtra(OCFileListFragment.SEARCH_EVENT, searchEvent); + startActivity(intent); + } + + public EcosystemManager getEcosystemManager() { + return ecosystemManager; + } + + /** + * sets the new/current account and restarts. In case the given account equals the actual/current account the call + * will be ignored. + * + * @param user to be set + */ + public void accountClicked(User user) { + final User currentUser = accountManager.getUser(); + if (!currentUser.nameEquals(user) && accountManager.setCurrentOwnCloudAccount(user)) { + fetchExternalLinks(true); + restart(); + } + } + + private void externalLinkClicked(MenuItem menuItem) { + externalLinksProvider.getExternalLink(ExternalLinkType.LINK, externalLinks -> { + for (ExternalLink link : externalLinks) { + final var menuTitle = menuItem.getTitle(); + if (menuTitle == null) { + continue; + } + + if (!menuTitle.toString().equalsIgnoreCase(link.getName())) { + continue; + } + + if (link.getRedirect()) { + DisplayUtils.startLinkIntent(DrawerActivity.this, link.getUrl()); + } else { + Intent externalWebViewIntent = new Intent(getApplicationContext(), ExternalSiteWebView.class); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_TITLE, link.getName()); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_URL, link.getUrl()); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_SHOW_SIDEBAR, true); + startActivity(externalWebViewIntent); + } + } + return Unit.INSTANCE; + }); + } + + /** + * checks if the drawer exists and is opened. + * + * @return true if the drawer is open, else false + */ + public boolean isDrawerOpen() { + return mDrawerLayout != null && mDrawerLayout.isDrawerOpen(GravityCompat.START); + } + + public void toggleDrawer() { + if (isDrawerOpen()) { + closeDrawer(); + } else { + openDrawer(); + } + } + + /** + * closes the drawer. + */ + public void closeDrawer() { + if (mDrawerLayout != null) { + mDrawerLayout.closeDrawer(GravityCompat.START); + } + } + + /** + * opens the drawer. + */ + public void openDrawer() { + if (mDrawerLayout != null) { + mDrawerLayout.openDrawer(GravityCompat.START); + updateExternalLinksInDrawer(); + updateQuotaLink(); + } + } + + /** + * Enable or disable interaction with all drawers. + * + * @param lockMode The new lock mode for the given drawer. One of {@link DrawerLayout#LOCK_MODE_UNLOCKED}, + * {@link DrawerLayout#LOCK_MODE_LOCKED_CLOSED} or {@link DrawerLayout#LOCK_MODE_LOCKED_OPEN}. + */ + public void setDrawerLockMode(int lockMode) { + if (mDrawerLayout != null) { + mDrawerLayout.setDrawerLockMode(lockMode); + } + } + + /** + * Enable or disable the drawer indicator. + * + * @param enable true to enable, false to disable + */ + public void setDrawerIndicatorEnabled(boolean enable) { + if (mDrawerToggle != null) { + mDrawerToggle.setDrawerIndicatorEnabled(enable); + } + } + + /** + * Updates title bar and home buttons (state and icon). Assumes that navigation drawer is NOT visible. + */ + protected void updateActionBarTitleAndHomeButton(OCFile chosenFile) { + super.updateActionBarTitleAndHomeButton(chosenFile); + + // set home button properties + if (mDrawerToggle != null) { + mDrawerToggle.setDrawerIndicatorEnabled(chosenFile != null && isRoot(chosenFile)); + } + } + + /** + * shows or hides the quota UI elements. + * + * @param showQuota show/hide quota information + */ + private void showQuota(boolean showQuota) { + if (showQuota) { + mQuotaView.setVisibility(View.VISIBLE); + } else { + mQuotaView.setVisibility(View.GONE); + } + } + + /** + * configured the quota to be displayed. + * + * @param usedSpace the used space + * @param totalSpace the total space + * @param relative the percentage of space already used + * @param quotaValue {@link GetUserInfoRemoteOperation#SPACE_UNLIMITED} or other to determinate state + */ + private void setQuotaInformation(long usedSpace, long totalSpace, int relative, long quotaValue) { + if (GetUserInfoRemoteOperation.SPACE_UNLIMITED == quotaValue) { + mQuotaTextPercentage.setText(String.format( + getString(R.string.drawer_quota_unlimited), + DisplayUtils.bytesToHumanReadable(usedSpace))); + } else { + mQuotaTextPercentage.setText(String.format( + getString(R.string.drawer_quota), + DisplayUtils.bytesToHumanReadable(usedSpace), + DisplayUtils.bytesToHumanReadable(totalSpace))); + } + + mQuotaProgressBar.setProgress(relative); + + if (relative < RELATIVE_THRESHOLD_WARNING) { + viewThemeUtils.material.colorProgressBar(mQuotaProgressBar, ColorRole.PRIMARY); + } else { + viewThemeUtils.material.colorProgressBar( + mQuotaProgressBar, + getResources().getColor(R.color.infolevel_warning, null) + ); + } + + updateQuotaLink(); + showQuota(true); + } + + private void updateQuotaLink() { + if (mQuotaTextLink == null) { + return; + } + + if (!MDMConfig.INSTANCE.externalSiteSupport(this)) { + mQuotaTextLink.setVisibility(View.GONE); + return; + } + + externalLinksProvider.getExternalLink(ExternalLinkType.QUOTA, quotas -> { + float density = getResources().getDisplayMetrics().density; + final int size = Math.round(24 * density); + if (quotas.isEmpty()) { + mQuotaTextLink.setVisibility(View.GONE); + return Unit.INSTANCE; + } + + final ExternalLink firstQuota = quotas.get(0); + mQuotaTextLink.setText(firstQuota.getName()); + mQuotaTextLink.setClickable(true); + mQuotaTextLink.setVisibility(View.VISIBLE); + mQuotaTextLink.setOnClickListener(v -> { + Intent externalWebViewIntent = new Intent(getApplicationContext(), ExternalSiteWebView.class); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_TITLE, firstQuota.getName()); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_URL, firstQuota.getUrl()); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_SHOW_SIDEBAR, true); + startActivity(externalWebViewIntent); + }); + + Target quotaTarget = createQuotaDrawableTarget(size, mQuotaTextLink); + GlideHelper.INSTANCE.loadIntoTarget(DrawerActivity.this, + accountManager.getCurrentOwnCloudAccount(), + firstQuota.getIconUrl(), + quotaTarget, + R.drawable.ic_link); + return Unit.INSTANCE; + }); + } + + private Target createQuotaDrawableTarget(int size, TextView quotaTextLink) { + return new CustomTarget<>() { + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + Drawable drawable = resource.getCurrent(); + drawable.setBounds(0, 0, size, size); + quotaTextLink.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + + } + + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + super.onLoadFailed(errorDrawable); + + Drawable drawable = errorDrawable != null ? errorDrawable.getCurrent() : null; + if (drawable != null) { + drawable.setBounds(0, 0, size, size); + quotaTextLink.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null); + } + } + }; + } + + + /** + * Retrieves and shows the user quota if available + */ + private void getAndDisplayUserQuota() { + // set user space information + Thread t = new Thread(() -> { + final User user = accountManager.getUser(); + + if (user.isAnonymous()) { + return; + } + + final Context context = MainApp.getAppContext(); + NextcloudClient nextcloudClient = null; + try { + nextcloudClient = OwnCloudClientManagerFactory + .getDefaultSingleton() + .getNextcloudClientFor(user.toOwnCloudAccount(), + context); + } catch (OperationCanceledException | AuthenticatorException | IOException e) { + Log_OC.e(this, "Error retrieving user quota", e); + } + + if (nextcloudClient == null) { + return; + } + + RemoteOperationResult result = new GetUserInfoRemoteOperation().execute(nextcloudClient); + + if (result.isSuccess() && result.getResultData() != null) { + final UserInfo userInfo = result.getResultData(); + final Quota quota = userInfo.getQuota(); + + if (quota != null) { + final long used = quota.getUsed(); + final long total = quota.getTotal(); + final int relative = (int) Math.ceil(quota.getRelative()); + final long quotaValue = quota.getQuota(); + + runOnUiThread(() -> { + if (quotaValue > 0 || quotaValue == GetUserInfoRemoteOperation.SPACE_UNLIMITED + || quotaValue == GetUserInfoRemoteOperation.QUOTA_LIMIT_INFO_NOT_AVAILABLE) { + /* + * show quota in case + * it is available and calculated (> 0) or + * in case of legacy servers (==QUOTA_LIMIT_INFO_NOT_AVAILABLE) + */ + setQuotaInformation(used, total, relative, quotaValue); + } else { + /* + * quotaValue < 0 means special cases like + * {@link RemoteGetUserQuotaOperation.SPACE_NOT_COMPUTED}, + * {@link RemoteGetUserQuotaOperation.SPACE_UNKNOWN} or + * {@link RemoteGetUserQuotaOperation.SPACE_UNLIMITED} + * thus don't display any quota information. + */ + showQuota(false); + } + }); + } + } + }); + + t.start(); + } + + private void updateExternalLinksInDrawer() { + if (drawerNavigationView == null || !MDMConfig.INSTANCE.externalSiteSupport(this)) { + return; + } + + drawerNavigationView.getMenu().removeGroup(R.id.drawer_menu_external_links); + + int greyColor = ContextCompat.getColor(this, R.color.drawer_menu_icon); + externalLinksProvider.getExternalLink(ExternalLinkType.LINK, externalLinks -> { + for (final ExternalLink link : externalLinks) { + int id = drawerNavigationView + .getMenu() + .add(R.id.drawer_menu_external_links, + MENU_ITEM_EXTERNAL_LINK + + link.getId(), + MENU_ORDER_EXTERNAL_LINKS, + link.getName() + ) + .setCheckable(true) + .getItemId(); + + Target iconTarget = createMenuItemTarget(id, greyColor); + GlideHelper.INSTANCE.loadIntoTarget(DrawerActivity.this, + accountManager.getCurrentOwnCloudAccount(), + link.getIconUrl(), + iconTarget, + R.drawable.ic_link); + } + return Unit.INSTANCE; + }); + } + + private Target createMenuItemTarget(int menuItemId, int tintColor) { + return new CustomTarget<>() { + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + setExternalLinkIcon(menuItemId, resource, tintColor); + } + + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + setExternalLinkIcon(menuItemId, errorDrawable, tintColor); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + + } + }; + } + + private void setExternalLinkIcon(int id, Drawable drawable, int greyColor) { + MenuItem menuItem = drawerNavigationView.getMenu().findItem(id); + if (menuItem == null) { + return; + } + + if (drawable == null) { + menuItem.setIcon(R.drawable.ic_link); + return; + } + + final var resizedDrawable = DrawableUtil.INSTANCE.getResizedDrawable(this, drawable,32); + menuItem.setIcon(viewThemeUtils.platform.colorDrawable(resizedDrawable, greyColor)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + mIsAccountChooserActive = savedInstanceState.getBoolean(KEY_IS_ACCOUNT_CHOOSER_ACTIVE, false); + } + + externalLinksProvider = new ExternalLinksProvider(getContentResolver()); + arbitraryDataProvider = new ArbitraryDataProviderImpl(this); + ecosystemManager = new EcosystemManager(this); + } + + @Override + protected void onDestroy() { + externalLinksProvider.cleanup(); + super.onDestroy(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(KEY_IS_ACCOUNT_CHOOSER_ACTIVE, mIsAccountChooserActive); + } + + @Override + public void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + mIsAccountChooserActive = savedInstanceState.getBoolean(KEY_IS_ACCOUNT_CHOOSER_ACTIVE, false); + highlightNavigationViewItem(getSelectedMenuItemId()); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + // Sync the toggle state after onRestoreInstanceState has occurred. + if (mDrawerToggle != null) { + mDrawerToggle.syncState(); + if (isDrawerOpen()) { + mDrawerToggle.setDrawerIndicatorEnabled(true); + } + } + updateExternalLinksInDrawer(); + updateQuotaLink(); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (mDrawerToggle != null) { + mDrawerToggle.onConfigurationChanged(newConfig); + } + } + + public void addOnBackPressedCallback() { + getOnBackPressedDispatcher().addCallback(new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (isDrawerOpen()) { + closeDrawer(); + return; + } + + final var fragment = getSupportFragmentManager().findFragmentByTag(FileDetailsSharingProcessFragment.TAG); + if (fragment instanceof FileDetailsSharingProcessFragment fileDetailsSharingProcessFragment) { + fileDetailsSharingProcessFragment.onBackPressed(); + } else { + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + } + } + }); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + // update Account list and active account if Manage Account activity replies with + // - ACCOUNT_LIST_CHANGED = true + // - RESULT_OK + if (requestCode == ACTION_MANAGE_ACCOUNTS && resultCode == RESULT_OK + && data.getBooleanExtra(ManageAccountsActivity.KEY_ACCOUNT_LIST_CHANGED, false)) { + + // current account has changed + if (data.getBooleanExtra(ManageAccountsActivity.KEY_CURRENT_ACCOUNT_CHANGED, false)) { + setAccount(accountManager.getCurrentAccount(), false); + restart(); + } + } else if (requestCode == PassCodeManager.PASSCODE_ACTIVITY && data != null) { + int result = data.getIntExtra(RequestCredentialsActivity.KEY_CHECK_RESULT, + RequestCredentialsActivity.KEY_CHECK_RESULT_FALSE); + + if (result == RequestCredentialsActivity.KEY_CHECK_RESULT_CANCEL) { + Log_OC.d(TAG, "PassCodeManager cancelled"); + preferences.setLockTimestamp(0); + finish(); + } + } else if (requestCode == REQ_ALL_FILES_ACCESS || requestCode == REQ_MEDIA_ACCESS) { + checkStoragePermissionWarningBannerVisibility(); + } + } + + /** + * Quota view can be either at navigation bottom or header + * + * @param id the view's id + * @return The view if found or null otherwise. + */ + private View findQuotaViewById(int id) { + View v = ((NavigationView) findViewById(R.id.nav_view)).getHeaderView(0).findViewById(id); + + if (v != null) { + return v; + } else { + return findViewById(id); + } + } + + /** + * restart helper method which is called after a changing the current account. + */ + private void restart() { + Intent i = new Intent(this, FileDisplayActivity.class); + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + i.setAction(FileDisplayActivity.RESTART); + startActivity(i); + + fetchExternalLinks(false); + } + + private void resetOnlyPersonalAndOnDevice() { + MainApp.showOnlyFilesOnDevice(false); + MainApp.showOnlyPersonalFiles(false); + } + + /** + * show the file list to the user. + * + * @param onDeviceOnly flag to decide if all files or only the ones on the device should be shown + */ + public void showFiles(boolean onDeviceOnly, boolean onlyPersonalFiles) { + MainApp.showOnlyFilesOnDevice(onDeviceOnly); + MainApp.showOnlyPersonalFiles(onlyPersonalFiles); + + Intent intent = new Intent(getApplicationContext(), FileDisplayActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setAction(FileDisplayActivity.ALL_FILES); + startActivity(intent); + } + + private void showOnDeviceFiles() { + MainApp.showOnlyFilesOnDevice(true); + MainApp.showOnlyPersonalFiles(false); + + Intent intent = new Intent(getApplicationContext(), FileDisplayActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setAction(FileDisplayActivity.ON_DEVICE); + startActivity(intent); + } + + @Override + public void avatarGenerated(Drawable avatarDrawable, Object callContext) { + if (callContext instanceof MenuItem menuItem) { + MenuItem newIcon = menuItem.setIcon(avatarDrawable); + Log_OC.d(TAG,"avatarGenerated new icon: " + newIcon); + } else if (callContext instanceof ImageView imageView) { + imageView.setImageDrawable(avatarDrawable); + } else if (callContext instanceof MaterialButton materialButton) { + materialButton.setIcon(avatarDrawable); + } + } + + @Override + public boolean shouldCallGeneratedCallback(String tag, Object callContext) { + if (callContext instanceof MenuItem menuItem) { + return String.valueOf(menuItem.getTitle()).equals(tag); + } else if (callContext instanceof ImageView imageView) { + return String.valueOf(imageView.getTag()).equals(tag); + } else if (callContext instanceof MaterialButton materialButton) { + return String.valueOf(materialButton.getTag()).equals(tag); + } + return false; + } + + /** + * Adds other listeners to react on changes of the drawer layout. + * + * @param listener Object interested in changes of the drawer layout. + */ + public void addDrawerListener(DrawerLayout.DrawerListener listener) { + if (mDrawerLayout != null) { + mDrawerLayout.addDrawerListener(listener); + } else { + Log_OC.e(TAG, "Drawer layout not ready to add drawer listener"); + } + } + + public boolean isDrawerIndicatorAvailable() { + return true; + } + + public AppPreferences getAppPreferences() { + return preferences; + } + + @Override + protected void onStart() { + super.onStart(); + EventBus.getDefault().register(this); + } + + @Override + protected void onStop() { + if (preferences.getLockTimestamp() != 0) { + preferences.setLockTimestamp(SystemClock.elapsedRealtime()); + } + EventBus.getDefault().unregister(this); + super.onStop(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAccountRemovedEvent(AccountRemovedEvent event) { + restart(); + } + + /** + * Retrieves external links via api from 'external' app + */ + public void fetchExternalLinks(final boolean force) { + if (!MDMConfig.INSTANCE.externalSiteSupport(this)) { + return; + } + + User user = accountManager.getUser(); + if (user.isAnonymous()) { + Log_OC.d(TAG, "Trying to execute a sync operation with a storage manager for an anonymous account"); + return; + } + + Thread t = new Thread(() -> { + // fetch capabilities as early as possible + final var optionalCapability = getCapabilities(); + if (optionalCapability.isPresent()) { + final var capability = optionalCapability.get(); + if ((capability.getAccountName() == null || !capability.getAccountName().isEmpty()) && getStorageManager() != null) { + GetCapabilitiesOperation getCapabilities = new GetCapabilitiesOperation(getStorageManager()); + getCapabilities.execute(getBaseContext()); + } + } + + if (getStorageManager() != null && CapabilityUtils.getCapability(user, this) + .getExternalLinks().isTrue()) { + + int count = arbitraryDataProvider.getIntegerValue(FilesSyncHelper.GLOBAL, + FileActivity.APP_OPENED_COUNT); + + if (count > 10 || count == -1 || force) { + if (force) { + Log_OC.d("ExternalLinks", "force update"); + } + + arbitraryDataProvider.storeOrUpdateKeyValue(FilesSyncHelper.GLOBAL, + FileActivity.APP_OPENED_COUNT, "0"); + + Log_OC.d("ExternalLinks", "update via api"); + RemoteOperation getExternalLinksOperation = new ExternalLinksOperation(); + RemoteOperationResult result = getExternalLinksOperation.execute(user, this); + + if (result.isSuccess() && result.getData() != null) { + externalLinksProvider.deleteAllExternalLinks(); + + ArrayList externalLinks = (ArrayList) (Object) result.getData(); + + for (ExternalLink link : externalLinks) { + externalLinksProvider.storeExternalLink(link); + } + } + } else { + arbitraryDataProvider.storeOrUpdateKeyValue(FilesSyncHelper.GLOBAL, + FileActivity.APP_OPENED_COUNT, String.valueOf(count + 1)); + } + } else { + externalLinksProvider.deleteAllExternalLinks(); + Log_OC.d("ExternalLinks", "links disabled"); + } + runOnUiThread(this::updateExternalLinksInDrawer); + }); + t.start(); + } + + protected void handleDeepLink(@NonNull Uri uri) { + String path = uri.getLastPathSegment(); + if (path == null) return; + + DeepLinkConstants deepLinkType = DeepLinkConstants.Companion.fromPath(path); + if (deepLinkType == null) { + DisplayUtils.showSnackMessage(this, getString(R.string.invalid_url)); + return; + } + + switch (deepLinkType) { + case OPEN_AUTO_UPLOAD: + startActivity(new Intent(this, SyncedFoldersActivity.class)); + break; + case OPEN_EXTERNAL_URL: + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri.getQueryParameter("url"))); + startActivity(intent); + break; + case ACTION_CREATE_NEW: + findViewById(R.id.fab_main).callOnClick(); + break; + case ACTION_APP_UPDATE: + LinkHelper.INSTANCE.openAppStore(getPackageName(), false, this); + break; + case OPEN_NOTIFICATIONS: + pushFragment(NavigatorScreen.Notifications.INSTANCE); + break; + default: + handleNavItemClickEvent(deepLinkType.getNavId()); + break; + } + } + + private void handleNavItemClickEvent(@IdRes int menuItemId) { + Menu navMenu = drawerNavigationView.getMenu(); + onNavigationItemClicked(navMenu.findItem(menuItemId)); + } + + public void showBottomNavigationBar(boolean show) { + ViewExtensionsKt.setVisibleIf(bottomNavigationView, show); + } + + public BottomNavigationView getBottomNavigationView() { + return bottomNavigationView; + } + + private void checkStoragePermissionWarningBannerVisibility() { + if (this instanceof SyncedFoldersActivity syncedFoldersActivity) { + syncedFoldersActivity.setupStoragePermissionWarningBanner(); + } else if (this instanceof UploadFilesActivity uploadFilesActivity) { + uploadFilesActivity.setupStoragePermissionWarningBanner(); + } + } + + private int getSelectedMenuItemId() { + if (drawerNavigationView == null) { + return R.id.nav_all_files; + } + + return NavigationViewExtensionsKt.getSelectedMenuItemId(drawerNavigationView); + } + + public boolean isToolbarStyleSearch() { + int menuItemId = getSelectedMenuItemId(); + + return menuItemId == Menu.NONE || + menuItemId == R.id.nav_all_files || + menuItemId == R.id.nav_personal_files; + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java new file mode 100644 index 000000000000..fb17f5a5bc7a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java @@ -0,0 +1,333 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity; + +import android.app.DownloadManager; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.net.Uri; +import android.os.Environment; +import android.os.Handler; +import android.view.View; +import android.webkit.JavascriptInterface; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebView; + +import com.google.android.material.snackbar.Snackbar; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.nextcloud.client.account.User; +import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.owncloud.android.R; +import com.owncloud.android.databinding.RichdocumentsWebviewBinding; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.SyncedFolderObserver; +import com.owncloud.android.datamodel.SyncedFolderProvider; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.ui.asynctasks.TextEditorLoadUrlTask; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.WebViewUtil; + +import java.util.ArrayList; +import java.util.Optional; + +import javax.inject.Inject; + +public abstract class EditorWebView extends ExternalSiteWebView { + public static final int REQUEST_LOCAL_FILE = 101; + public ValueCallback uploadMessage; + protected Snackbar loadingSnackbar; + + protected String fileName; + + RichdocumentsWebviewBinding binding; + + @Inject SyncedFolderProvider syncedFolderProvider; + + protected void loadUrl(String url) { + onUrlLoaded(url); + } + + protected void hideLoading() { + binding.thumbnail.setVisibility(View.GONE); + binding.filename.setVisibility(View.GONE); + binding.progressBar2.setVisibility(View.GONE); + getWebView().setVisibility(View.VISIBLE); + + if (loadingSnackbar != null) { + loadingSnackbar.dismiss(); + } + } + + public void onUrlLoaded(String loadedUrl) { + this.url = loadedUrl; + + if (!url.isEmpty()) { + new WebViewUtil().setProxyKKPlus(this.getWebView()); + try { + Thread.sleep(1000); + } catch (InterruptedException ignored) { + } + + if (!url.equals(this.getWebView().getUrl())) { + this.getWebView().loadUrl(url); + } + + new Handler().postDelayed(() -> { + if (this.getWebView().getVisibility() != View.VISIBLE) { + Snackbar snackbar = DisplayUtils.createSnackbar(findViewById(android.R.id.content), + R.string.timeout_richDocuments, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.common_cancel, v -> closeView()); + + viewThemeUtils.material.themeSnackbar(snackbar); + setLoadingSnackbar(snackbar); + snackbar.show(); + } + }, 10 * 1000); + } else { + DisplayUtils.showSnackMessage(this,R.string.richdocuments_failed_to_load_document); + finish(); + } + } + + public void closeView() { + getWebView().destroy(); + finish(); + } + + public void reload() { + if (getWebView().getVisibility() != View.VISIBLE) { + return; + } + + Optional user = getUser(); + if (!user.isPresent()) { + return; + } + + OCFile file = getFile(); + if (file != null) { + TextEditorLoadUrlTask task = new TextEditorLoadUrlTask(this, user.get(), file, editorUtils); + task.execute(); + } + } + + @Override + protected void bindView() { + binding = RichdocumentsWebviewBinding.inflate(getLayoutInflater()); + } + + @Override + protected void postOnCreate() { + super.postOnCreate(); + + viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar2, ColorRole.PRIMARY); + + getWebView().setWebChromeClient(new WebChromeClient() { + final EditorWebView activity = EditorWebView.this; + + @Override + public boolean onShowFileChooser(WebView webView, ValueCallback filePathCallback, + FileChooserParams fileChooserParams) { + if (uploadMessage != null) { + uploadMessage.onReceiveValue(null); + uploadMessage = null; + } + + activity.uploadMessage = filePathCallback; + + Intent intent = fileChooserParams.createIntent(); + intent.setType("image/*"); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + try { + activity.startActivityForResult(intent, REQUEST_LOCAL_FILE); + } catch (ActivityNotFoundException e) { + uploadMessage = null; + DisplayUtils.showSnackMessage(EditorWebView.this, R.string.editor_web_view_cannot_open_file); + return false; + } + + return true; + } + }); + + setFile(IntentExtensionsKt.getParcelableArgument(getIntent(), ExternalSiteWebView.EXTRA_FILE, OCFile.class)); + + if (getFile() == null) { + DisplayUtils.showSnackMessage(this, R.string.richdocuments_failed_to_load_document); + finish(); + } + + if (getFile() != null) { + fileName = getFile().getFileName(); + } + + Optional user = getUser(); + if (!user.isPresent()) { + finish(); + return; + } + initLoadingScreen(user.get()); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (RESULT_OK != resultCode) { + if (requestCode == REQUEST_LOCAL_FILE) { + this.uploadMessage.onReceiveValue(null); + this.uploadMessage = null; + } + return; + } + + handleActivityResult(requestCode, resultCode, data); + + super.onActivityResult(requestCode, resultCode, data); + } + + protected void handleActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_LOCAL_FILE) { + handleLocalFile(data, resultCode); + } + } + + protected void handleLocalFile(Intent data, int resultCode) { + if (uploadMessage == null) { + return; + } + + if (data.getClipData() == null) { + // one file + uploadMessage.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data)); + } else { + ArrayList uris = new ArrayList<>(); + // multiple files + for (int i = 0; i < data.getClipData().getItemCount(); i++) { + ClipData.Item item = data.getClipData().getItemAt(i); + uris.add(item.getUri()); + } + + uploadMessage.onReceiveValue(uris.toArray(new Uri[0])); + } + + uploadMessage = null; + } + + protected WebView getWebView() { + return binding.webView; + } + + protected View getRootView() { + return binding.getRoot(); + } + + protected boolean showToolbarByDefault() { + return false; + } + + protected void initLoadingScreen(final User user) { + setThumbnailView(user); + binding.filename.setText(fileName); + } + + private void openShareDialog() { + Intent intent = new Intent(this, ShareActivity.class); + intent.putExtra(FileActivity.EXTRA_FILE, getFile()); + intent.putExtra(FileActivity.EXTRA_USER, getUser().orElseThrow(RuntimeException::new)); + startActivity(intent); + } + + protected void setThumbnailView(final User user) { + // Todo minimize: only icon by mimetype + OCFile file = getFile(); + if (file.isFolder()) { + boolean isAutoUploadFolder = SyncedFolderObserver.INSTANCE.isAutoUploadFolder(file, user); + + Integer overlayIconId = file.getFileOverlayIconId(isAutoUploadFolder); + LayerDrawable drawable = MimeTypeUtil.getFolderIcon(preferences.isDarkModeEnabled(), overlayIconId, this, viewThemeUtils); + binding.thumbnail.setImageDrawable(drawable); + } else { + if ((MimeTypeUtil.isImage(file) || MimeTypeUtil.isVideo(file)) && file.getRemoteId() != null) { + // Thumbnail in cache? + Bitmap thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.getRemoteId()); + + if (thumbnail != null && !file.isUpdateThumbnailNeeded()) { + if (MimeTypeUtil.isVideo(file)) { + Bitmap withOverlay = ThumbnailsCacheManager.addVideoOverlay(thumbnail, this); + binding.thumbnail.setImageBitmap(withOverlay); + } else { + binding.thumbnail.setImageBitmap(thumbnail); + } + } + + if ("image/png".equalsIgnoreCase(file.getMimeType())) { + binding.thumbnail.setBackgroundColor(getResources().getColor(R.color.bg_default, getTheme())); + } + } else { + Drawable icon = MimeTypeUtil.getFileTypeIcon(file.getMimeType(), + file.getFileName(), + getApplicationContext(), + viewThemeUtils); + binding.thumbnail.setImageDrawable(icon); + } + } + } + + protected void downloadFile(Uri url, String fileName) { + DownloadManager downloadmanager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); + + if (downloadmanager == null) { + DisplayUtils.showSnackMessage(getWebView(), getString(R.string.failed_to_download)); + return; + } + + DownloadManager.Request request = new DownloadManager.Request(url); + request.allowScanningByMediaScanner(); + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); + + // change the name file and your current activity. + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); + + + downloadmanager.enqueue(request); + } + + public void setLoadingSnackbar(Snackbar loadingSnackbar) { + this.loadingSnackbar = loadingSnackbar; + } + + public class MobileInterface { + @JavascriptInterface + public void close() { + runOnUiThread(EditorWebView.this::closeView); + } + + @JavascriptInterface + public void share() { + openShareDialog(); + } + + @JavascriptInterface + public void loaded() { + runOnUiThread(EditorWebView.this::hideLoading); + } + + @JavascriptInterface + public void reload() { + EditorWebView.this.reload(); + } + } + +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ErrorsWhileCopyingHandlerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ErrorsWhileCopyingHandlerActivity.java new file mode 100644 index 000000000000..e7452f0acd4c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ErrorsWhileCopyingHandlerActivity.java @@ -0,0 +1,266 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2013 María Asensio Valverde + * SPDX-FileCopyrightText: 2012-2013 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.text.method.ScrollingMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ListView; +import android.widget.TextView; + +import com.nextcloud.client.account.User; +import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.dialog.IndeterminateProgressDialog; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.FileStorageUtils; + +import java.io.File; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.DialogFragment; + +/** + * Activity reporting errors occurred when local files uploaded to an Nextcloud account with an app + * in version under 1.3.16 where being copied to the ownCloud local folder. + * Allows the user move the files to the Nextcloud local folder. let them unlinked to the remote files. + * Shown when the error notification summarizing the list of errors is clicked by the user. + */ +public class ErrorsWhileCopyingHandlerActivity extends AppCompatActivity implements OnClickListener { + + private static final String TAG = ErrorsWhileCopyingHandlerActivity.class.getSimpleName(); + + public static final String EXTRA_USER = + ErrorsWhileCopyingHandlerActivity.class.getCanonicalName() + ".EXTRA_ACCOUNT"; + public static final String EXTRA_LOCAL_PATHS = + ErrorsWhileCopyingHandlerActivity.class.getCanonicalName() + ".EXTRA_LOCAL_PATHS"; + public static final String EXTRA_REMOTE_PATHS = + ErrorsWhileCopyingHandlerActivity.class.getCanonicalName() + ".EXTRA_REMOTE_PATHS"; + + private static final String WAIT_DIALOG_TAG = "WAIT_DIALOG"; + + protected User user; + protected FileDataStorageManager mStorageManager; + protected List mLocalPaths; + protected List mRemotePaths; + protected ArrayAdapter mAdapter; + protected Handler mHandler; + private DialogFragment mCurrentDialog; + + /** + * {@link} + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + /// read extra parameters in intent + Intent intent = getIntent(); + user = IntentExtensionsKt.getParcelableArgument(intent, EXTRA_USER, User.class); + mRemotePaths = intent.getStringArrayListExtra(EXTRA_REMOTE_PATHS); + mLocalPaths = intent.getStringArrayListExtra(EXTRA_LOCAL_PATHS); + mStorageManager = new FileDataStorageManager(user, getContentResolver()); + mHandler = new Handler(); + if (mCurrentDialog != null) { + mCurrentDialog.dismiss(); + mCurrentDialog = null; + } + + /// load generic layout + setContentView(R.layout.generic_explanation); + + /// customize text message + TextView textView = findViewById(R.id.message); + String appName = getString(R.string.app_name); + String message = String.format(getString(R.string.sync_foreign_files_forgotten_explanation), + appName, appName, appName, appName, user.getAccountName()); + textView.setText(message); + textView.setMovementMethod(new ScrollingMovementMethod()); + + /// load the list of local and remote files that failed + ListView listView = findViewById(R.id.list); + if (mLocalPaths != null && mLocalPaths.size() > 0) { + mAdapter = new ErrorsWhileCopyingListAdapter(); + listView.setAdapter(mAdapter); + } else { + listView.setVisibility(View.GONE); + mAdapter = null; + } + + /// customize buttons + Button cancelBtn = findViewById(R.id.cancel); + Button okBtn = findViewById(R.id.ok); + + okBtn.setText(R.string.foreign_files_move); + cancelBtn.setOnClickListener(this); + okBtn.setOnClickListener(this); + } + + /** + * Customized adapter, showing the local files as main text in two-lines list item and the + * remote files as the secondary text. + */ + public class ErrorsWhileCopyingListAdapter extends ArrayAdapter { + + ErrorsWhileCopyingListAdapter() { + super(ErrorsWhileCopyingHandlerActivity.this, android.R.layout.two_line_list_item, + android.R.id.text1, mLocalPaths); + } + + @Override + public boolean isEnabled(int position) { + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public View getView (int position, View convertView, @NonNull ViewGroup parent) { + View view = convertView; + if (view == null) { + LayoutInflater vi = (LayoutInflater) getSystemService( + Context.LAYOUT_INFLATER_SERVICE); + view = vi.inflate(android.R.layout.two_line_list_item, null); + } + if (view != null) { + String localPath = getItem(position); + if (localPath != null) { + TextView text1 = view.findViewById(android.R.id.text1); + if (text1 != null) { + text1.setText(String.format(getString(R.string.foreign_files_local_text), localPath)); + } + } + if (mRemotePaths != null && mRemotePaths.size() > 0 && position >= 0 && + position < mRemotePaths.size()) { + TextView text2 = view.findViewById(android.R.id.text2); + String remotePath = mRemotePaths.get(position); + if (text2 != null && remotePath != null) { + text2.setText(String.format(getString(R.string.foreign_files_remote_text), remotePath)); + } + } + } + return view; + } + } + + + /** + * Listener method to perform the MOVE / CANCEL action available in this activity. + * + * @param v Clicked view (button MOVE or CANCEL) + */ + @Override + public void onClick(View v) { + if (v.getId() == R.id.ok) { + /// perform movement operation in background thread + Log_OC.d(TAG, "Clicked MOVE, start movement"); + new MoveFilesTask().execute(); + + } else if (v.getId() == R.id.cancel) { + /// just finish + Log_OC.d(TAG, "Clicked CANCEL, bye"); + finish(); + + } else { + Log_OC.e(TAG, "Clicked phantom button, id: " + v.getId()); + } + } + + + /** + * Asynchronous task performing the move of all the local files to the ownCloud folder. + */ + @SuppressLint("StaticFieldLeak") + private class MoveFilesTask extends AsyncTask { + + /** + * Updates the UI before trying the movement + */ + @Override + protected void onPreExecute () { + /// progress dialog and disable 'Move' button + mCurrentDialog = IndeterminateProgressDialog.newInstance(R.string.wait_a_moment, false); + mCurrentDialog.show(getSupportFragmentManager(), WAIT_DIALOG_TAG); + findViewById(R.id.ok).setEnabled(false); + } + + + /** + * Performs the movement + * + * @return 'False' when the movement of any file fails. + */ + @Override + protected Boolean doInBackground(Void... params) { + while (!mLocalPaths.isEmpty()) { + String currentPath = mLocalPaths.get(0); + File currentFile = new File(currentPath); + String expectedPath = FileStorageUtils.getSavePath(user.getAccountName()) + mRemotePaths.get(0); + File expectedFile = new File(expectedPath); + + if (expectedFile.equals(currentFile) || currentFile.renameTo(expectedFile)) { + // SUCCESS + OCFile file = mStorageManager.getFileByPath(mRemotePaths.get(0)); + file.setStoragePath(expectedPath); + mStorageManager.saveFile(file); + mRemotePaths.remove(0); + mLocalPaths.remove(0); + + } else { + // FAIL + return Boolean.FALSE; + } + } + return Boolean.TRUE; + } + + /** + * Updates the activity UI after the movement of local files is tried. + * + * If the movement was successful for all the files, finishes the activity immediately. + * + * In other case, the list of remaining files is still available to retry the movement. + * + * @param result 'True' when the movement was successful. + */ + @Override + protected void onPostExecute(Boolean result) { + mAdapter.notifyDataSetChanged(); + mCurrentDialog.dismiss(); + mCurrentDialog = null; + findViewById(R.id.ok).setEnabled(true); + + if (result) { + // nothing else to do in this activity + finish(); + } else { + DisplayUtils.showSnackMessage(findViewById(android.R.id.content), R.string.foreign_files_fail); + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ExtendedSettingsActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ExtendedSettingsActivity.kt new file mode 100644 index 000000000000..47e60dbea9ee --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ExtendedSettingsActivity.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.activity + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.owncloud.android.ui.model.ExtendedSettingsActivityDialog + +class ExtendedSettingsActivity : AppCompatActivity() { + + @Suppress("ReturnCount") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val dialogKey = intent.getStringExtra(EXTRA_DIALOG_TYPE) ?: run { + finish() + return + } + + val dialogType = ExtendedSettingsActivityDialog.entries.find { it.key == dialogKey } ?: run { + finish() + return + } + + val dismissable = intent.getBooleanExtra(EXTRA_DISMISSABLE, true) + dialogType.showDialog(this, dismissable) + } + + companion object { + private const val EXTRA_DISMISSABLE = "dismissable" + private const val EXTRA_DIALOG_TYPE = "dialog_type" + + @JvmOverloads + fun createIntent( + context: Context, + dialogType: ExtendedSettingsActivityDialog, + dismissable: Boolean = true + ): Intent = Intent(context, ExtendedSettingsActivity::class.java).apply { + putExtra(EXTRA_DIALOG_TYPE, dialogType.key) + putExtra(EXTRA_DISMISSABLE, dismissable) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ExternalSiteWebView.java b/app/src/main/java/com/owncloud/android/ui/activity/ExternalSiteWebView.java new file mode 100644 index 000000000000..fe1107d1b140 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ExternalSiteWebView.java @@ -0,0 +1,237 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.ui.activity; + +import android.annotation.SuppressLint; +import android.content.pm.ApplicationInfo; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.widget.ProgressBar; + +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.databinding.ExternalsiteWebviewBinding; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.NextcloudWebViewClient; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.WebViewUtil; + +import java.io.InputStream; + +import androidx.appcompat.app.ActionBar; +import androidx.drawerlayout.widget.DrawerLayout; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * This activity shows an URL as a web view + */ +public class ExternalSiteWebView extends FileActivity { + public static final String EXTRA_TITLE = "TITLE"; + public static final String EXTRA_URL = "URL"; + public static final String EXTRA_SHOW_SIDEBAR = "SHOW_SIDEBAR"; + public static final String EXTRA_SHOW_TOOLBAR = "SHOW_TOOLBAR"; + public static final String EXTRA_TEMPLATE = "TEMPLATE"; + + private static final String TAG = ExternalSiteWebView.class.getSimpleName(); + + protected boolean showToolbar = true; + private ExternalsiteWebviewBinding binding; + private boolean showSidebar; + String url; + + @Override + protected final void onCreate(Bundle savedInstanceState) { + Log_OC.v(TAG, "onCreate() start"); + bindView(); + showToolbar = showToolbarByDefault(); + + Bundle extras = getIntent().getExtras(); + url = getIntent().getExtras().getString(EXTRA_URL); + if (extras.containsKey(EXTRA_SHOW_TOOLBAR)) { + showToolbar = extras.getBoolean(EXTRA_SHOW_TOOLBAR); + } + + showSidebar = extras.getBoolean(EXTRA_SHOW_SIDEBAR); + + // show progress + Window window = getWindow(); + if (window != null) { + window.requestFeature(Window.FEATURE_PROGRESS); + } + + super.onCreate(savedInstanceState); + + setContentView(getRootView()); + + postOnCreate(); + } + + protected void postOnCreate() { + final WebSettings webSettings = getWebView().getSettings(); + + getWebView().setFocusable(true); + getWebView().setFocusableInTouchMode(true); + getWebView().setClickable(true); + + // allow debugging (when building the debug version); see details in + // https://developers.google.com/web/tools/chrome-devtools/remote-debugging/webviews + if ((getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0 || + getResources().getBoolean(R.bool.is_beta)) { + Log_OC.d(this, "Enable debug for webView"); + WebView.setWebContentsDebuggingEnabled(true); + } + + // setup toolbar + if (showToolbar) { + setupToolbar(); + } else { + if (findViewById(R.id.appbar) != null) { + findViewById(R.id.appbar).setVisibility(View.GONE); + } + } + + setupDrawer(R.id.nav_view); + + if (!showSidebar) { + setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); + } + + String title = getIntent().getExtras().getString(EXTRA_TITLE); + if (!TextUtils.isEmpty(title)) { + setupActionBar(title); + } + setupWebSettings(webSettings); + + final ProgressBar progressBar = findViewById(R.id.progressBar); + + if (progressBar != null) { + getWebView().setWebChromeClient(new WebChromeClient() { + public void onProgressChanged(WebView view, int progress) { + progressBar.setProgress(progress * 1000); + } + }); + } + + final ExternalSiteWebView self = this; + getWebView().setWebViewClient(new NextcloudWebViewClient(getSupportFragmentManager()) { + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + InputStream resources = getResources().openRawResource(R.raw.custom_error); + String customError = DisplayUtils.getData(resources); + + if (!customError.isEmpty()) { + getWebView().loadData(customError, "text/html; charset=UTF-8", null); + } + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (!request.isRedirect()) { + DisplayUtils.startLinkIntent(self, request.getUrl()); + return true; + } + return false; + } + }); + + new WebViewUtil().setProxyKKPlus(getWebView()); + getWebView().loadUrl(url); + } + + @Override + protected void onDestroy() { + getWebView().destroy(); + super.onDestroy(); + } + + protected void bindView() { + binding = ExternalsiteWebviewBinding.inflate(getLayoutInflater()); + } + + protected boolean showToolbarByDefault() { + return true; + } + + protected View getRootView() { + return binding.getRoot(); + } + + @SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT") + @SuppressLint("SetJavaScriptEnabled") + private void setupWebSettings(WebSettings webSettings) { + // enable zoom + webSettings.setSupportZoom(true); + webSettings.setBuiltInZoomControls(true); + webSettings.setDisplayZoomControls(false); + + // Non-responsive webs are zoomed out when loaded + webSettings.setUseWideViewPort(true); + webSettings.setLoadWithOverviewMode(true); + + // user agent + webSettings.setUserAgentString(MainApp.getUserAgent()); + + // do not store private data + webSettings.setSaveFormData(false); + + // disable local file access + webSettings.setAllowFileAccess(false); + + // enable javascript + webSettings.setJavaScriptEnabled(true); + webSettings.setDomStorageEnabled(true); + + // caching disabled in debug mode + if ((getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) { + webSettings.setCacheMode(WebSettings.LOAD_NO_CACHE); + } + } + + private void setupActionBar(String title) { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + viewThemeUtils.files.themeActionBar(this, actionBar, title); + + if (showSidebar) { + actionBar.setDisplayHomeAsUpEnabled(true); + } else { + setDrawerIndicatorEnabled(false); + } + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + if (showSidebar) { + if (isDrawerOpen()) { + closeDrawer(); + } else { + openDrawer(); + } + } else { + finish(); + } + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + protected WebView getWebView() { + return binding.webView; + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java new file mode 100644 index 000000000000..ec834e983c1d --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java @@ -0,0 +1,1044 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 TSI-mc + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2017-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2013 David A. Velasco + * SPDX-FileCopyrightText: 2011 Bartek Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorException; +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.net.ConnectivityManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.text.TextUtils; + +import com.google.android.material.snackbar.Snackbar; +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.jobs.BackgroundJobManager; +import com.nextcloud.client.jobs.download.FileDownloadWorker; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.receiver.NetworkChangeListener; +import com.nextcloud.receiver.NetworkChangeReceiver; +import com.nextcloud.utils.EditorUtils; +import com.nextcloud.utils.extensions.ActivityExtensionsKt; +import com.nextcloud.utils.extensions.BundleExtensionsKt; +import com.nextcloud.utils.extensions.FileExtensionsKt; +import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.nextcloud.utils.mdm.MDMConfig; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.authentication.AuthenticatorActivity; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.OwnCloudCredentials; +import com.owncloud.android.lib.common.network.CertificateCombinedException; +import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.model.ServerFileInterface; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.operations.CreateShareViaLinkOperation; +import com.owncloud.android.operations.CreateShareWithShareeOperation; +import com.owncloud.android.operations.GetSharesForFileOperation; +import com.owncloud.android.operations.SetFilesDownloadLimitOperation; +import com.owncloud.android.operations.SynchronizeFileOperation; +import com.owncloud.android.operations.SynchronizeFolderOperation; +import com.owncloud.android.operations.UnshareOperation; +import com.owncloud.android.operations.UpdateNoteForShareOperation; +import com.owncloud.android.operations.UpdateShareInfoOperation; +import com.owncloud.android.operations.UpdateSharePermissionsOperation; +import com.owncloud.android.operations.UpdateShareViaLinkOperation; +import com.owncloud.android.providers.UsersAndGroupsSearchConfig; +import com.owncloud.android.providers.UsersAndGroupsSearchProvider; +import com.owncloud.android.services.OperationsService; +import com.owncloud.android.services.OperationsService.OperationsServiceBinder; +import com.owncloud.android.ui.asynctasks.CheckRemoteWipeTask; +import com.owncloud.android.ui.asynctasks.LoadingVersionNumberTask; +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment; +import com.owncloud.android.ui.dialog.LoadingDialog; +import com.owncloud.android.ui.dialog.ShareLinkToDialog; +import com.owncloud.android.ui.dialog.SslUntrustedCertDialog; +import com.owncloud.android.ui.events.DialogEvent; +import com.owncloud.android.ui.events.DialogEventType; +import com.owncloud.android.ui.fragment.FileDetailFragment; +import com.owncloud.android.ui.fragment.FileDetailSharingFragment; +import com.owncloud.android.ui.fragment.OCFileListFragment; +import com.owncloud.android.ui.fragment.filesRepository.FilesRepository; +import com.owncloud.android.ui.fragment.filesRepository.RemoteFilesRepository; +import com.owncloud.android.ui.helpers.FileOperationsHelper; +import com.owncloud.android.ui.preview.PreviewImageActivity; +import com.owncloud.android.ui.preview.PreviewMediaActivity; +import com.owncloud.android.utils.ClipboardUtil; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.ErrorMessageAdapter; +import com.owncloud.android.utils.FilesSyncHelper; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; + +import static com.owncloud.android.ui.activity.FileDisplayActivity.TAG_PUBLIC_LINK; + +/** + * Activity with common behaviour for activities handling {@link OCFile}s in ownCloud {@link Account}s . + */ +public abstract class FileActivity extends DrawerActivity + implements OnRemoteOperationListener, ComponentsGetter, SslUntrustedCertDialog.OnSslUntrustedCertListener, + LoadingVersionNumberTask.VersionDevInterface, FileDetailSharingFragment.OnEditShareListener, NetworkChangeListener { + + public static final String EXTRA_FILE = "com.owncloud.android.ui.activity.FILE"; + public static final String EXTRA_FILE_REMOTE_PATH = "com.owncloud.android.ui.activity.FILE_REMOTE_PATH"; + public static final String EXTRA_LIVE_PHOTO_FILE = "com.owncloud.android.ui.activity.LIVE.PHOTO.FILE"; + public static final String EXTRA_USER = "com.owncloud.android.ui.activity.USER"; + public static final String EXTRA_FROM_NOTIFICATION = "com.owncloud.android.ui.activity.FROM_NOTIFICATION"; + public static final String APP_OPENED_COUNT = "APP_OPENED_COUNT"; + public static final String EXTRA_SEARCH = "com.owncloud.android.ui.activity.SEARCH"; + public static final String EXTRA_SEARCH_QUERY = "com.owncloud.android.ui.activity.SEARCH_QUERY"; + + public static final String TAG = FileActivity.class.getSimpleName(); + + private static final String DIALOG_WAIT_TAG = "DIALOG_WAIT"; + + private static final String KEY_WAITING_FOR_OP_ID = "WAITING_FOR_OP_ID"; + private static final String KEY_ACTION_BAR_TITLE = "ACTION_BAR_TITLE"; + + public static final int REQUEST_CODE__UPDATE_CREDENTIALS = 0; + public static final int REQUEST_CODE__LAST_SHARED = REQUEST_CODE__UPDATE_CREDENTIALS; + + protected static final long DELAY_TO_REQUEST_OPERATIONS_LATER = 200; + + /* Dialog tags */ + private static final String DIALOG_UNTRUSTED_CERT = "DIALOG_UNTRUSTED_CERT"; + private static final String DIALOG_CERT_NOT_SAVED = "DIALOG_CERT_NOT_SAVED"; + + /** Main {@link OCFile} handled by the activity.*/ + private OCFile mFile; + + /** Flag to signal if the activity is launched by a notification */ + private boolean mFromNotification; + + /** Messages handler associated to the main thread and the life cycle of the activity */ + private Handler mHandler; + + private FileOperationsHelper mFileOperationsHelper; + + private ServiceConnection mOperationsServiceConnection; + + private OperationsServiceBinder mOperationsServiceBinder; + + private boolean mResumed; + + protected FileDownloadWorker.FileDownloadProgressListener fileDownloadProgressListener; + protected FileUploadHelper fileUploadHelper = FileUploadHelper.Companion.instance(); + protected boolean isFileDisplayActivityResumed = false; + + @Inject + public UserAccountManager accountManager; + + @Inject public ConnectivityService connectivityService; + + @Inject + protected BackgroundJobManager backgroundJobManager; + + @Inject + EditorUtils editorUtils; + + @Inject + UsersAndGroupsSearchConfig usersAndGroupsSearchConfig; + + @Inject + ArbitraryDataProvider arbitraryDataProvider; + + private NetworkChangeReceiver networkChangeReceiver; + + private FilesRepository filesRepository; + + private void registerNetworkChangeReceiver() { + IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); + registerReceiver(networkChangeReceiver, filter); + } + + @Override + public void showFiles(boolean onDeviceOnly, boolean personalFiles) { + // must be specialized in subclasses + MainApp.showOnlyFilesOnDevice(onDeviceOnly); + MainApp.showOnlyPersonalFiles(personalFiles); + if (onDeviceOnly) { + setupToolbar(); + } else { + setupHomeSearchToolbarWithSortAndListButtons(); + } + } + + /** + * Loads the ownCloud {@link Account} and main {@link OCFile} to be handled by the instance of + * the {@link FileActivity}. + * + * Grants that a valid ownCloud {@link Account} is associated to the instance, or that the user + * is requested to create a new one. + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + networkChangeReceiver = new NetworkChangeReceiver(this, connectivityService); + usersAndGroupsSearchConfig.reset(); + mHandler = new Handler(); + mFileOperationsHelper = new FileOperationsHelper(this, getUserAccountManager(), connectivityService, editorUtils); + User user; + + if (savedInstanceState != null) { + mFile = BundleExtensionsKt.getParcelableArgument(savedInstanceState, FileActivity.EXTRA_FILE, OCFile.class); + mFromNotification = savedInstanceState.getBoolean(FileActivity.EXTRA_FROM_NOTIFICATION); + mFileOperationsHelper.setOpIdWaitingFor( + savedInstanceState.getLong(KEY_WAITING_FOR_OP_ID, Long.MAX_VALUE) + ); + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null && !(this instanceof PreviewImageActivity)) { + viewThemeUtils.files.themeActionBar(this, actionBar, savedInstanceState.getString(KEY_ACTION_BAR_TITLE)); + } + } else { + user = IntentExtensionsKt.getParcelableArgument(getIntent(), FileActivity.EXTRA_USER, User.class); + mFile = IntentExtensionsKt.getParcelableArgument(getIntent(), FileActivity.EXTRA_FILE, OCFile.class); + mFromNotification = getIntent().getBooleanExtra(FileActivity.EXTRA_FROM_NOTIFICATION, + false); + + if (user != null) { + setUser(user); + } + } + + mOperationsServiceConnection = new OperationsServiceConnection(); + bindService(new Intent(this, OperationsService.class), mOperationsServiceConnection, + Context.BIND_AUTO_CREATE); + registerNetworkChangeReceiver(); + + filesRepository = new RemoteFilesRepository(getClientRepository(), this); + } + + @Override + public void networkAndServerConnectionListener(boolean isNetworkAndServerAvailable) { + if (isNetworkAndServerAvailable) { + hideInfoBox(); + + // No need to refresh the file list again since file display activity doing it. + if (!isFileDisplayActivityResumed) { + refreshList(); + } + } else { + if (this instanceof PreviewMediaActivity) { + hideInfoBox(); + } else { + showInfoBox(R.string.offline_mode); + } + } + } + + @Override + protected void onStart() { + super.onStart(); + fetchExternalLinks(false); + } + + @Override + protected void onResume() { + super.onResume(); + mResumed = true; + if (mOperationsServiceBinder != null) { + doOnResumeAndBound(); + } + } + + @Override + protected void onPause() { + if (mOperationsServiceBinder != null) { + mOperationsServiceBinder.removeOperationListener(this); + } + mResumed = false; + super.onPause(); + } + + @Override + protected void onDestroy() { + if (mOperationsServiceConnection != null) { + unbindService(mOperationsServiceConnection); + mOperationsServiceBinder = null; + } + + unregisterReceiver(networkChangeReceiver); + + super.onDestroy(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + FileExtensionsKt.logFileSize(mFile, TAG); + outState.putParcelable(FileActivity.EXTRA_FILE, mFile); + outState.putBoolean(FileActivity.EXTRA_FROM_NOTIFICATION, mFromNotification); + outState.putLong(KEY_WAITING_FOR_OP_ID, mFileOperationsHelper.getOpIdWaitingFor()); + + final var actionBar = getSupportActionBar(); + + if(actionBar != null) { + final var actionBarTitle = actionBar.getTitle(); + if (actionBarTitle != null) { + outState.putString(KEY_ACTION_BAR_TITLE, actionBarTitle.toString()); + } + } + } + + /** + * Getter for the main {@link OCFile} handled by the activity. + * + * @return Main {@link OCFile} handled by the activity. + */ + @Nullable + public OCFile getFile() { + return mFile; + } + + + /** + * Setter for the main {@link OCFile} handled by the activity. + * + * @param file Main {@link OCFile} to be handled by the activity. + */ + public void setFile(OCFile file) { + mFile = file; + } + + /** + * @return Value of mFromNotification: True if the Activity is launched by a notification + */ + public boolean fromNotification() { + return mFromNotification; + } + + public OperationsServiceBinder getOperationsServiceBinder() { + return mOperationsServiceBinder; + } + + protected ServiceConnection newTransferenceServiceConnection() { + return null; + } + + public OnRemoteOperationListener getRemoteOperationListener() { + return this; + } + + public Handler getHandler() { + return mHandler; + } + + public FileOperationsHelper getFileOperationsHelper() { + return mFileOperationsHelper; + } + + /** + * + * @param operation Operation performed. + * @param result Result of the removal. + */ + @Override + public void onRemoteOperationFinish(RemoteOperation operation, RemoteOperationResult result) { + Log_OC.d(TAG, "Received result of operation in FileActivity - common behaviour for all the " + + "FileActivities "); + + mFileOperationsHelper.setOpIdWaitingFor(Long.MAX_VALUE); + + dismissLoadingDialog(); + + if (!result.isSuccess() && ( + result.getCode() == ResultCode.UNAUTHORIZED || + (result.isException() && result.getException() instanceof AuthenticatorException) + )) { + + requestCredentialsUpdate(); + + if (result.getCode() == ResultCode.UNAUTHORIZED) { + DisplayUtils.showSnackMessage( + this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ); + } + + } else if (!result.isSuccess() && ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED == result.getCode()) { + + showUntrustedCertDialog(result); + + } else if (operation == null || + operation instanceof CreateShareWithShareeOperation || + operation instanceof UnshareOperation || + operation instanceof SynchronizeFolderOperation || + operation instanceof UpdateShareViaLinkOperation || + operation instanceof UpdateSharePermissionsOperation + ) { + if (result.isSuccess()) { + updateFileFromDB(); + + } else if (result.getCode() != ResultCode.CANCELLED) { + DisplayUtils.showSnackMessage( + this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ); + } + + } else if (operation instanceof SynchronizeFileOperation) { + onSynchronizeFileOperationFinish((SynchronizeFileOperation) operation, result); + + } else if (operation instanceof GetSharesForFileOperation) { + if (result.isSuccess() || result.getCode() == ResultCode.SHARE_NOT_FOUND) { + updateFileFromDB(); + + } else { + DisplayUtils.showSnackMessage(this, + ErrorMessageAdapter.getErrorCauseMessage(result, + operation, + getResources())); + } + } + + if (operation instanceof CreateShareViaLinkOperation) { + onCreateShareViaLinkOperationFinish((CreateShareViaLinkOperation) operation, result); + } else if (operation instanceof CreateShareWithShareeOperation) { + onUpdateShareInformation(result, R.string.sharee_add_failed); + } else if (operation instanceof UpdateShareViaLinkOperation || operation instanceof UpdateShareInfoOperation) { + onUpdateShareInformation(result, R.string.updating_share_failed); + } else if (operation instanceof SetFilesDownloadLimitOperation) { + onUpdateShareInformation(result, R.string.set_download_limit_failed); + } else if (operation instanceof UpdateSharePermissionsOperation) { + onUpdateShareInformation(result, R.string.updating_share_failed); + } else if (operation instanceof UnshareOperation) { + onUpdateShareInformation(result, R.string.unsharing_failed); + } else if (operation instanceof UpdateNoteForShareOperation) { + onUpdateNoteForShareOperationFinish(result); + } + } + + /** + * Invalidates the credentials stored for the current OC account and requests new credentials to the user, + * navigating to {@link AuthenticatorActivity} + * + * Equivalent to call requestCredentialsUpdate(context, null); + * + */ + protected void requestCredentialsUpdate() { + requestCredentialsUpdate(null); + } + + /** + * Invalidates the credentials stored for the given OC account and requests new credentials to the user, + * navigating to {@link AuthenticatorActivity} + * + * @param account Stored OC account to request credentials update for. If null, current account will + * be used. + */ + protected void requestCredentialsUpdate(Account account) { + if (account == null) { + account = getAccount(); + } + + new CheckRemoteWipeTask(backgroundJobManager, account, new WeakReference<>(this)).execute(); + } + + public void performCredentialsUpdate(Account account, Context context) { + try { + /// step 1 - invalidate credentials of current account + OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); + OwnCloudClient client = OwnCloudClientManagerFactory.getDefaultSingleton().removeClientFor(ocAccount); + + if (client != null) { + OwnCloudCredentials credentials = client.getCredentials(); + if (credentials != null) { + AccountManager accountManager = AccountManager.get(context); + if (credentials.authTokenExpires()) { + accountManager.invalidateAuthToken(account.type, credentials.getAuthToken()); + } else { + accountManager.clearPassword(account); + } + } + } + + /// step 2 - request credentials to user + Intent updateAccountCredentials = new Intent(context, AuthenticatorActivity.class); + updateAccountCredentials.putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, account); + updateAccountCredentials.putExtra( + AuthenticatorActivity.EXTRA_ACTION, + AuthenticatorActivity.ACTION_UPDATE_EXPIRED_TOKEN); + updateAccountCredentials.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + startActivityForResult(updateAccountCredentials, REQUEST_CODE__UPDATE_CREDENTIALS); + } catch (com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException e) { + DisplayUtils.showSnackMessage(this, R.string.auth_account_does_not_exist); + } + } + + /** + * Show untrusted cert dialog + */ + public void showUntrustedCertDialog(RemoteOperationResult result) { + // Show a dialog with the certificate info + FragmentManager fm = getSupportFragmentManager(); + SslUntrustedCertDialog dialog = (SslUntrustedCertDialog) fm.findFragmentByTag(DIALOG_UNTRUSTED_CERT); + if(dialog == null) { + dialog = SslUntrustedCertDialog.newInstanceForFullSslError( + (CertificateCombinedException) result.getException()); + FragmentTransaction ft = fm.beginTransaction(); + dialog.show(ft, DIALOG_UNTRUSTED_CERT); + } + } + + private void onSynchronizeFileOperationFinish(SynchronizeFileOperation operation, + RemoteOperationResult result) { + OCFile syncedFile = operation.getLocalFile(); + if (!result.isSuccess()) { + if (result.getCode() == ResultCode.SYNC_CONFLICT) { + Intent intent = ConflictsResolveActivity.createIntent(syncedFile, + getUser().orElseThrow(RuntimeException::new), + -1, + null, + this); + startActivity(intent); + } + + } else { + if (!operation.transferWasRequested()) { + DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, + operation, getResources())); + } + supportInvalidateOptionsMenu(); + } + } + + protected void updateFileFromDB(){ + OCFile file = getFile(); + if (file != null) { + file = getStorageManager().getFileByPath(file.getRemotePath()); + setFile(file); + } + } + + /** + * Show loading dialog + */ + public void showLoadingDialog(String message) { + runOnUiThread(() -> { + if (!ActivityExtensionsKt.isActive(this)) { + Log_OC.w(TAG, "cannot show loading dialog, activity is finishing"); + return; + } + + FragmentManager fragmentManager = getSupportFragmentManager(); + fragmentManager.executePendingTransactions(); + Fragment existingDialog = fragmentManager.findFragmentByTag(DIALOG_WAIT_TAG); + + if (existingDialog instanceof LoadingDialog loadingDialog) { + Log_OC.d(TAG, "dismiss previous loading dialog"); + + if (!fragmentManager.isStateSaved()) { + loadingDialog.dismiss(); + } else { + loadingDialog.dismissAllowingStateLoss(); + } + } + + // Show new dialog + if (!fragmentManager.isStateSaved()) { + Log_OC.d(TAG, "show loading dialog"); + LoadingDialog loadingDialogFragment = LoadingDialog.newInstance(message); + loadingDialogFragment.show(fragmentManager, DIALOG_WAIT_TAG); + } + }); + } + + /** + * Dismiss loading dialog + */ + public void dismissLoadingDialog() { + runOnUiThread(() -> { + if (!ActivityExtensionsKt.isActive(this)) { + Log_OC.w(TAG, "cannot dismiss loading dialog, activity is finishing"); + return; + } + + FragmentManager fragmentManager = getSupportFragmentManager(); + fragmentManager.executePendingTransactions(); + Fragment fragment = fragmentManager.findFragmentByTag(DIALOG_WAIT_TAG); + + if (fragment instanceof LoadingDialog loadingDialogFragment) { + Log_OC.d(TAG, "dismiss loading dialog"); + + // Avoid dismissing after state is saved + if (!fragmentManager.isStateSaved()) { + loadingDialogFragment.dismiss(); + } else { + // Dismiss allowing state loss if needed + loadingDialogFragment.dismissAllowingStateLoss(); + } + } + }); + } + + private void doOnResumeAndBound() { + mOperationsServiceBinder.addOperationListener(this, mHandler); + long waitingForOpId = mFileOperationsHelper.getOpIdWaitingFor(); + if (waitingForOpId <= Integer.MAX_VALUE) { + boolean wait = mOperationsServiceBinder.dispatchResultIfFinished((int)waitingForOpId, + this); + if (!wait ) { + dismissLoadingDialog(); + } + } + } + + /** + * Implements callback methods for service binding. Passed as a parameter to { + */ + private class OperationsServiceConnection implements ServiceConnection { + + @Override + public void onServiceConnected(ComponentName component, IBinder service) { + if (component.equals(new ComponentName(FileActivity.this, OperationsService.class))) { + Log_OC.d(TAG, "Operations service connected"); + mOperationsServiceBinder = (OperationsServiceBinder) service; + /*if (!mOperationsServiceBinder.isPerformingBlockingOperation()) { + dismissLoadingDialog(); + }*/ + if (mResumed) { + doOnResumeAndBound(); + } + + } else { + return; + } + } + + @Override + public void onServiceDisconnected(ComponentName component) { + if (component.equals(new ComponentName(FileActivity.this, OperationsService.class))) { + Log_OC.d(TAG, "Operations service disconnected"); + mOperationsServiceBinder = null; + // TODO whatever could be waiting for the service is unbound + } + } + } + + @Override + public FileDownloadWorker.FileDownloadProgressListener getFileDownloadProgressListener() { + return fileDownloadProgressListener; + } + + @Override + public FileUploadHelper getFileUploaderHelper() { + return fileUploadHelper; + } + + @Nullable + public OCFile getCurrentDir() { + OCFile file = getFile(); + if (file != null) { + if (file.isFolder()) { + return file; + } else if (getStorageManager() != null) { + String parentPath = file.getParentRemotePath(); + return getStorageManager().getFileByPath(parentPath); + } + } + return null; + } + + /* OnSslUntrustedCertListener methods */ + + @Override + public void onSavedCertificate() { + // Nothing to do in this context + } + + @Override + public void onFailedSavingCertificate() { + ConfirmationDialogFragment dialog = ConfirmationDialogFragment.newInstance( + R.string.ssl_validator_not_saved, new String[]{}, 0, R.string.common_ok, -1, -1 + ); + dialog.show(getSupportFragmentManager(), DIALOG_CERT_NOT_SAVED); + } + + public void checkForNewDevVersionNecessary(Context context) { + if (getResources().getBoolean(R.bool.dev_version_direct_download_enabled)) { + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(this); + int count = arbitraryDataProvider.getIntegerValue(FilesSyncHelper.GLOBAL, APP_OPENED_COUNT); + + if (count > 10 || count == -1) { + checkForNewDevVersion(this, context); + } + } + } + + @Override + public void returnVersion(Integer latestVersion) { + showDevSnackbar(this, latestVersion, false, true); + } + + public static void checkForNewDevVersion(LoadingVersionNumberTask.VersionDevInterface callback, Context context) { + String url = context.getString(R.string.dev_latest); + LoadingVersionNumberTask loadTask = new LoadingVersionNumberTask(callback); + loadTask.execute(url); + } + + public static void showDevSnackbar(Activity activity, + Integer latestVersion, + boolean openDirectly, + boolean inBackground) { + int currentVersion = -1; + try { + currentVersion = activity.getPackageManager().getPackageInfo(activity.getPackageName(), 0).versionCode; + } catch (PackageManager.NameNotFoundException e) { + Log_OC.e(TAG, "Package not found", e); + } + + if (latestVersion == -1 || currentVersion == -1) { + DisplayUtils.showSnackMessage(activity, R.string.dev_version_no_information_available, Snackbar.LENGTH_LONG); + } + if (latestVersion > currentVersion) { + String devApkLink = activity.getString(R.string.dev_link) + latestVersion + ".apk"; + if (openDirectly) { + DisplayUtils.startLinkIntent(activity, devApkLink); + } else { + Snackbar.make(activity.findViewById(android.R.id.content), R.string.dev_version_new_version_available, + Snackbar.LENGTH_LONG) + .setAction(activity.getString(R.string.version_dev_download), v -> DisplayUtils.startLinkIntent(activity, devApkLink)).show(); + } + } else { + if (!inBackground) { + DisplayUtils.showSnackMessage(activity, R.string.dev_version_no_new_version_available, Snackbar.LENGTH_LONG); + } + } + } + + public static void copyAndShareFileLink(FileActivity activity, + OCFile file, + String link, + final ViewThemeUtils viewThemeUtils) { + if (MDMConfig.INSTANCE.shareViaLink(activity) && MDMConfig.INSTANCE.clipBoardSupport(activity)) { + ClipboardUtil.copyToClipboard(activity, link, false); + Snackbar snackbar = Snackbar.make(activity.findViewById(android.R.id.content), R.string.clipboard_text_copied, + Snackbar.LENGTH_LONG) + .setAction(R.string.share, v -> showShareLinkDialog(activity, file, link)); + viewThemeUtils.material.themeSnackbar(snackbar); + snackbar.show(); + } + } + + public static void showShareLinkDialog(FileActivity activity, ServerFileInterface file, String link) { + // Create dialog to allow the user choose an app to send the link + Intent intentToShareLink = new Intent(Intent.ACTION_SEND); + + intentToShareLink.putExtra(Intent.EXTRA_TEXT, link); + intentToShareLink.setType("text/plain"); + + String username; + try { + OwnCloudAccount oca = new OwnCloudAccount(activity.getAccount(), activity); + if (oca.getDisplayName() != null && !oca.getDisplayName().isEmpty()) { + username = oca.getDisplayName(); + } else { + username = com.owncloud.android.lib.common.accounts.AccountUtils + .getUsernameForAccount(activity.getAccount()); + } + } catch (Exception e) { + username = com.owncloud.android.lib.common.accounts.AccountUtils + .getUsernameForAccount(activity.getAccount()); + } + + if (username != null) { + intentToShareLink.putExtra(Intent.EXTRA_SUBJECT, + activity.getString(R.string.subject_user_shared_with_you, + username, + file.getFileName())); + } else { + intentToShareLink.putExtra(Intent.EXTRA_SUBJECT, + activity.getString(R.string.subject_shared_with_you, + file.getFileName())); + } + + String[] packagesToExclude = new String[]{activity.getPackageName()}; + DialogFragment chooserDialog = ShareLinkToDialog.newInstance(intentToShareLink, packagesToExclude); + chooserDialog.show(activity.getSupportFragmentManager(), FileDisplayActivity.FTAG_CHOOSER_DIALOG); + } + + private void onUpdateNoteForShareOperationFinish(RemoteOperationResult result) { + FileDetailSharingFragment sharingFragment = getShareFileFragment(); + + if (result.isSuccess()) { + if (sharingFragment != null) { + sharingFragment.onUpdateShareInformation(result); + } + } else { + DisplayUtils.showSnackMessage(this, R.string.note_could_not_sent); + } + } + + private void onUpdateShareInformation(RemoteOperationResult result, @StringRes int defaultError) { + Snackbar snackbar; + FileDetailSharingFragment sharingFragment = getShareFileFragment(); + + if (result.isSuccess()) { + updateFileFromDB(); + if (sharingFragment != null) { + sharingFragment.onUpdateShareInformation(result); + } + } else if (sharingFragment != null && sharingFragment.getView() != null) { + if (TextUtils.isEmpty(result.getMessage())) { + snackbar = Snackbar.make(sharingFragment.getView(), defaultError, Snackbar.LENGTH_LONG); + } else { + snackbar = Snackbar.make(sharingFragment.getView(), result.getMessage(), Snackbar.LENGTH_LONG); + } + + viewThemeUtils.material.themeSnackbar(snackbar); + snackbar.show(); + } + } + + public void refreshList() { + final Fragment fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES); + if (fragment instanceof OCFileListFragment listFragment) { + listFragment.onRefresh(); + } else if (fragment instanceof FileDetailFragment detailFragment) { + detailFragment.goBackToOCFileListFragment(); + } + } + + private void onCreateShareViaLinkOperationFinish(CreateShareViaLinkOperation operation, + RemoteOperationResult result) { + FileDetailSharingFragment sharingFragment = getShareFileFragment(); + final Fragment fileListFragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES); + + if (result.isSuccess()) { + updateFileFromDB(); + + // if share to user and share via link multiple ocshares are returned, + // therefore filtering for public_link + String link = ""; + OCFile file = null; + for (Object object : result.getData()) { + if (object instanceof OCShare shareLink) { + ShareType shareType = shareLink.getShareType(); + + if (shareType != null && TAG_PUBLIC_LINK.equalsIgnoreCase(shareType.name())) { + link = shareLink.getShareLink(); + file = getStorageManager().getFileByEncryptedRemotePath(shareLink.getPath()); + break; + } + } + } + + copyAndShareFileLink(this, file, link, viewThemeUtils); + + if (sharingFragment != null) { + sharingFragment.onUpdateShareInformation(result, file); + } + + if (fileListFragment instanceof OCFileListFragment ocFileListFragment && file != null) { + if (ocFileListFragment.getAdapterFiles().contains(file)) { + ocFileListFragment.updateOCFile(file); + } else { + DisplayUtils.showSnackMessage(this, R.string.file_activity_shared_file_cannot_be_updated); + } + } + } else { + // Detect Failure (403) --> maybe needs password + String password = operation.getPassword(); + final var optionalCapabilities = getCapabilities(); + + if (result.getCode() == RemoteOperationResult.ResultCode.SHARE_FORBIDDEN && TextUtils.isEmpty(password)) { + if (optionalCapabilities.isPresent()) { + final var capabilities = optionalCapabilities.get(); + if (capabilities.getFilesSharingPublicEnabled().isUnknown()) { + // Was tried without password, but not sure that it's optional. + // Try with password before giving up; see also ShareFileFragment#OnShareViaLinkListener + if (sharingFragment != null && sharingFragment.isAdded()) { + // only if added to the view hierarchy + sharingFragment.requestPasswordForShareViaLink(true, capabilities.getFilesSharingPublicAskForOptionalPassword().isTrue()); + } + } + } + } else { + if (sharingFragment != null) { + sharingFragment.refreshSharesFromDB(); + } + Snackbar snackbar = Snackbar.make(findViewById(android.R.id.content), + ErrorMessageAdapter.getErrorCauseMessage(result, + operation, + getResources()), + Snackbar.LENGTH_LONG); + viewThemeUtils.material.themeSnackbar(snackbar); + snackbar.show(); + } + } + } + + /** + * Shortcut to get access to the {@link FileDetailSharingFragment} instance, if any + * + * @return A {@link FileDetailSharingFragment} instance, or null + */ + private @Nullable + @Deprecated + FileDetailSharingFragment getShareFileFragment() { + Fragment fragment = getSupportFragmentManager().findFragmentByTag(ShareActivity.TAG_SHARE_FRAGMENT); + + if (fragment == null) { + fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES); + } + + if (fragment instanceof FileDetailSharingFragment) { + return (FileDetailSharingFragment) fragment; + } else if (fragment instanceof FileDetailFragment fileDetailFragment) { + return fileDetailFragment.getFileDetailSharingFragment(); + } else { + return null; + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + + if (UsersAndGroupsSearchProvider.ACTION_SHARE_WITH.equals(intent.getAction())) { + Uri data = intent.getData(); + String dataString = intent.getDataString(); + String shareWith = dataString.substring(dataString.lastIndexOf('/') + 1); + + ArrayList existingSharees = new ArrayList<>(); + for (OCShare share : getStorageManager().getSharesWithForAFile(getFileFromDetailFragment().getRemotePath(), + getAccount().name)) { + existingSharees.add(share.getShareType() + "_" + share.getShareWith()); + } + + String dataAuthority = data.getAuthority(); + ShareType shareType = UsersAndGroupsSearchProvider.getShareType(dataAuthority); + + if (!existingSharees.contains(shareType + "_" + shareWith)) { + doShareWith(shareWith, shareType); + } else { + DisplayUtils.showSnackMessage(this, getString(R.string.sharee_already_added_to_file)); + } + } + } + + /** + * returns the file that is selected for sharing, getFile() only returns the containing folder + */ + private OCFile getFileFromDetailFragment() { + FileDetailFragment fragment = getFileDetailFragment(); + if (fragment != null) { + return fragment.getFile(); + } + return getFile(); + } + + /** + * open the new sharing process fragment to create the share + * + * @param shareeName + * @param shareType + */ + protected void doShareWith(String shareeName, ShareType shareType) { + FileDetailFragment fragment = getFileDetailFragment(); + if (fragment != null) { + fragment.initiateSharingProcess(shareeName, + shareType, + usersAndGroupsSearchConfig.getSearchOnlyUsers()); + } + } + + /** + * open the new sharing process to modify the created share + * @param share + * @param screenTypePermission + * @param isReshareShown + */ + @Override + public void editExistingShare(OCShare share, int screenTypePermission, boolean isReshareShown) { + FileDetailFragment fragment = getFileDetailFragment(); + if (fragment != null) { + fragment.editExistingShare(share, screenTypePermission, isReshareShown); + } + } + + /** + * callback triggered on closing/finishing the sharing process + */ + @Override + public void onShareProcessClosed() { + FileDetailFragment fragment = getFileDetailFragment(); + if (fragment != null) { + fragment.showHideFragmentView(false); + } + } + + private FileDetailFragment getFileDetailFragment() { + Fragment fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES); + if (fragment instanceof FileDetailFragment) { + return (FileDetailFragment) fragment; + } + return null; + } + + public FilesRepository getFilesRepository() { + return filesRepository; + } + + public void showSyncLoadingDialog(boolean isFolder) { + if (isFolder) { + return; + } + + showLoadingDialog(getApplicationContext().getString(R.string.wait_a_moment)); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void handleSyncDialogEvent(DialogEvent event) { + if (event.getType() == DialogEventType.SYNC) { + dismissLoadingDialog(); + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt new file mode 100644 index 000000000000..864df474b964 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -0,0 +1,3260 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Philipp Hasper + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2023-2024 TSI-mc + * SPDX-FileCopyrightText: 2023 Archontis E. Kostis + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018-2020 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2012-2013 David A. Velasco + * SPDX-FileCopyrightText: 2011 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity + +import android.accounts.AuthenticatorException +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.content.res.Resources +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Parcelable +import android.text.TextUtils +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.WindowManager.BadTokenException +import android.view.inputmethod.InputMethodManager +import androidx.activity.OnBackPressedCallback +import androidx.annotation.VisibleForTesting +import androidx.appcompat.widget.SearchView +import androidx.core.util.Function +import androidx.core.view.MenuItemCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.common.core.utils.ecosystem.AccountReceiverCallback +import com.nextcloud.appReview.InAppReviewHelper +import com.nextcloud.client.account.User +import com.nextcloud.client.appinfo.AppInfo +import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.core.Clock +import com.nextcloud.client.database.entity.SyncedFolderEntity +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.editimage.EditImageActivity +import com.nextcloud.client.files.DeepLinkHandler +import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.jobs.download.FileDownloadWorker +import com.nextcloud.client.jobs.folderDownload.FolderDownloadEventBroadcaster +import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.media.PlayerServiceConnection +import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.utils.IntentUtil +import com.nextcloud.model.WorkerState.OfflineOperationsCompleted +import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.isActive +import com.nextcloud.utils.extensions.isDialogFragmentReady +import com.nextcloud.utils.extensions.lastFragment +import com.nextcloud.utils.extensions.logFileSize +import com.nextcloud.utils.extensions.navigateToAllFiles +import com.nextcloud.utils.extensions.observeWorker +import com.nextcloud.utils.fileNameValidator.FileNameValidator.checkFolderPath +import com.nextcloud.utils.view.FastScrollUtils +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.FilesBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.VirtualFolderType +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.RestoreFileVersionRemoteOperation +import com.owncloud.android.lib.resources.files.SearchRemoteOperation +import com.owncloud.android.lib.resources.notifications.GetNotificationsRemoteOperation +import com.owncloud.android.operations.CopyFileOperation +import com.owncloud.android.operations.CreateFolderOperation +import com.owncloud.android.operations.DownloadType +import com.owncloud.android.operations.FolderRefreshScheduler +import com.owncloud.android.operations.MoveFileOperation +import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.operations.RemoveFileOperation +import com.owncloud.android.operations.RenameFileOperation +import com.owncloud.android.operations.SynchronizeFileOperation +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.syncadapter.FileSyncAdapter +import com.owncloud.android.ui.CompletionCallback +import com.owncloud.android.ui.asynctasks.CheckAvailableSpaceTask +import com.owncloud.android.ui.asynctasks.CheckAvailableSpaceTask.CheckAvailableSpaceListener +import com.owncloud.android.ui.asynctasks.FetchRemoteFileTask +import com.owncloud.android.ui.asynctasks.GetRemoteFileTask +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.DeleteBatchTracker +import com.owncloud.android.ui.dialog.SendShareDialog.SendShareDialogDownloader +import com.owncloud.android.ui.dialog.SortingOrderDialogFragment.OnSortingOrderListener +import com.owncloud.android.ui.dialog.StoragePermissionDialogFragment +import com.owncloud.android.ui.dialog.TermsOfServiceDialog +import com.owncloud.android.ui.events.SearchEvent +import com.owncloud.android.ui.events.SyncEventFinished +import com.owncloud.android.ui.events.TokenPushEvent +import com.owncloud.android.ui.fragment.EmptyListState +import com.owncloud.android.ui.fragment.FileDetailFragment +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.fragment.GalleryFragment +import com.owncloud.android.ui.fragment.GroupfolderListFragment +import com.owncloud.android.ui.fragment.OCFileListFragment +import com.owncloud.android.ui.fragment.SearchType +import com.owncloud.android.ui.fragment.SharedListFragment +import com.owncloud.android.ui.fragment.TaskRetainerFragment +import com.owncloud.android.ui.fragment.UnifiedSearchFragment +import com.owncloud.android.ui.helpers.FileOperationsHelper +import com.owncloud.android.ui.helpers.UriUploader +import com.owncloud.android.ui.interfaces.TransactionInterface +import com.owncloud.android.ui.navigation.NavigatorScreen +import com.owncloud.android.ui.preview.PreviewImageActivity +import com.owncloud.android.ui.preview.PreviewImageFragment +import com.owncloud.android.ui.preview.PreviewMediaActivity +import com.owncloud.android.ui.preview.PreviewMediaFragment +import com.owncloud.android.ui.preview.PreviewMediaFragment.Companion.newInstance +import com.owncloud.android.ui.preview.PreviewTextFileFragment +import com.owncloud.android.ui.preview.PreviewTextFragment +import com.owncloud.android.ui.preview.PreviewTextStringFragment +import com.owncloud.android.ui.preview.pdf.PreviewPdfFragment.Companion.newInstance +import com.owncloud.android.utils.DataHolderUtil +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.ErrorMessageAdapter +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.PermissionUtil +import com.owncloud.android.utils.PermissionUtil.requestNotificationPermission +import com.owncloud.android.utils.PermissionUtil.requestStoragePermissionIfNeeded +import com.owncloud.android.utils.PushUtils +import com.owncloud.android.utils.StringUtils +import com.owncloud.android.utils.UriUtils +import com.owncloud.android.utils.theme.CapabilityUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.commons.io.FilenameUtils +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.io.File +import java.util.function.Supplier +import javax.inject.Inject + +/** + * Displays, what files the user has available in his Nextcloud. This is the main view. + */ +@Suppress( + "ComplexCondition", + "SpreadOperator", + "ForbiddenComment", + "ReturnCount", + "LargeClass", + "NestedBlockDepth", + "TooManyFunctions" +) +class FileDisplayActivity : + FileActivity(), + FileFragment.ContainerActivity, + OnEnforceableRefreshListener, + OnSortingOrderListener, + SendShareDialogDownloader, + OnFilesRemovedListener, + Injectable { + private lateinit var binding: FilesBinding + + private val syncReceiver = SyncReceiver() + private val fileUploadCompletedReceiver = FileUploadCompletedReceiver() + + private val fileDownloadStartedReceiver = FileDownloadStartedReceiver() + private val fileDownloadCompletedReceiver = FileDownloadCompletedReceiver() + + private val folderDownloadStartedReceiver = FolderDownloadStartedReceiver() + private val folderDownloadCompletedReceiver = FolderDownloadCompletedReceiver() + + private var mLastSslUntrustedServerResult: RemoteOperationResult<*>? = null + + private var mWaitingToPreview: OCFile? = null + + private var mSyncInProgress: Boolean = false + set(value) { + field = value + setEmptyListState() + } + + private var mWaitingToSend: OCFile? = null + + private var mDrawerMenuItemstoShowHideList: MutableCollection? = null + + private var searchQuery: String? = "" + private var searchOpen = false + + private var searchView: SearchView? = null + private var mPlayerConnection: PlayerServiceConnection? = null + private var lastDisplayedAccountName: String? = null + + @Inject + lateinit var localBroadcastManager: LocalBroadcastManager + + @Inject + lateinit var composeProcessTextAlias: ComposeProcessTextAlias + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var appInfo: AppInfo + + @Inject + lateinit var inAppReviewHelper: InAppReviewHelper + + @Inject + lateinit var fastScrollUtils: FastScrollUtils + + @Inject + lateinit var asyncRunner: AsyncRunner + + @Inject + lateinit var clock: Clock + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + /** + * Indicates whether the downloaded file should be previewed immediately. Since `FileDownloadWorker` can be + * triggered from multiple sources, this helps determine if an automatic preview is needed after download. + */ + private var fileIDForImmediatePreview: Long = -1 + + private lateinit var folderRefreshScheduler: FolderRefreshScheduler + + fun setFileIDForImmediatePreview(fileIDForImmediatePreview: Long) { + this.fileIDForImmediatePreview = fileIDForImmediatePreview + } + + @SuppressLint("UnsafeIntentLaunch") + override fun onCreate(savedInstanceState: Bundle?) { + Log_OC.v(TAG, "onCreate() start") + // Set the default theme to replace the launch screen theme. + setTheme(R.style.Theme_ownCloud_Toolbar_Drawer) + + super.onCreate(savedInstanceState) + lastDisplayedAccountName = preferences.lastDisplayedAccountName + folderRefreshScheduler = FolderRefreshScheduler(this) + + intent?.let { + handleCommonIntents(it) + handleEcosystemIntent(it) + } + + loadSavedInstanceState(savedInstanceState) + + // USER INTERFACE + initLayout() + initUI() + initTaskRetainerFragment() + + // Restoring after UI has been inflated. + if (savedInstanceState != null) { + showSortListGroup(savedInstanceState.getBoolean(KEY_IS_SORT_GROUP_VISIBLE)) + } + + mPlayerConnection = PlayerServiceConnection(this) + + checkStoragePath() + + observeWorkerState() + startMetadataSyncForRoot() + handleBackPress() + setupDrawer(menuItemId) + } + + /** + * Determines which navigation drawer item should be selected. + * + * Resolution order: + * 1) Global app state (static flags in MainApp nav_personal and nav_on_device) + * 2) Currently visible fragment (and its active child) + * 3) Fallback to All Files + */ + override fun getMenuItemId(): Int = MainApp.getMenuItemId() ?: listOfFilesFragment?.menuItemId ?: R.id.nav_all_files + + private fun loadSavedInstanceState(savedInstanceState: Bundle?) { + if (savedInstanceState != null) { + mWaitingToPreview = + savedInstanceState.getParcelableArgument(KEY_WAITING_TO_PREVIEW, OCFile::class.java) + mSyncInProgress = savedInstanceState.getBoolean(KEY_SYNC_IN_PROGRESS) + mWaitingToSend = savedInstanceState.getParcelableArgument(KEY_WAITING_TO_SEND, OCFile::class.java) + searchQuery = savedInstanceState.getString(KEY_SEARCH_QUERY) + searchOpen = savedInstanceState.getBoolean(KEY_IS_SEARCH_OPEN, false) + } else { + mWaitingToPreview = null + mSyncInProgress = false + mWaitingToSend = null + } + } + + private fun initLayout() { + // Inflate and set the layout view + binding = FilesBinding.inflate(layoutInflater) + setContentView(binding.getRoot()) + } + + private fun initUI() { + setupHomeSearchToolbarWithSortAndListButtons() + mMenuButton.setOnClickListener { v: View? -> openDrawer() } + mSwitchAccountButton.setOnClickListener { v: View? -> showManageAccountsDialog() } + mNotificationButton.setOnClickListener { + pushFragment(NavigatorScreen.Notifications) + } + fastScrollUtils.fixAppBarForFastScroll(binding.appbar.appbar, binding.rootLayout) + + // reset ui states when file display activity created/recrated + listOfFilesFragment?.resetSearchAttributes() + } + + private fun initTaskRetainerFragment() { + // Init Fragment without UI to retain AsyncTask across configuration changes + val fm = supportFragmentManager + var taskRetainerFragment = + fm.findFragmentByTag(TaskRetainerFragment.FTAG_TASK_RETAINER_FRAGMENT) as TaskRetainerFragment? + if (taskRetainerFragment == null) { + taskRetainerFragment = TaskRetainerFragment() + fm.beginTransaction().add(taskRetainerFragment, TaskRetainerFragment.FTAG_TASK_RETAINER_FRAGMENT).commit() + } // else, Fragment already created and retained across configuration change + } + + private fun checkStoragePath() { + val newStorage = Environment.getExternalStorageDirectory().absolutePath + val storagePath = preferences.getStoragePath(newStorage) + if (!preferences.isStoragePathValid() && !File(storagePath).exists()) { + // falling back to default + preferences.setStoragePath(newStorage) + preferences.setStoragePathValid() + MainApp.setStoragePath(newStorage) + + try { + val builder = MaterialAlertDialogBuilder(this, R.style.Theme_ownCloud_Dialog) + .setTitle(R.string.wrong_storage_path) + .setMessage(R.string.wrong_storage_path_desc) + .setPositiveButton( + R.string.dialog_close + ) { dialog: DialogInterface?, which: Int -> dialog?.dismiss() } + .setIcon(R.drawable.ic_settings) + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(applicationContext, builder) + + builder.create().show() + } catch (e: BadTokenException) { + Log_OC.e(TAG, "Error showing wrong storage info, so skipping it: " + e.message) + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val fragment = + supportFragmentManager.findFragmentByTag( + PermissionUtil.PERMISSION_CHOICE_DIALOG_TAG + ) as StoragePermissionDialogFragment? + if (fragment != null) { + val dialog = fragment.dialog + + if (dialog != null && dialog.isShowing) { + dialog.dismiss() + supportFragmentManager.beginTransaction().remove(fragment).commitNowAllowingStateLoss() + requestStoragePermissionIfNeeded(this) + } + } + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + + // handle notification permission on API level >= 33 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // request notification permission first and then prompt for storage permissions + // storage permissions handled in onRequestPermissionsResult + requestNotificationPermission(this) + } else { + requestStoragePermissionIfNeeded(this) + } + + if (intent.getParcelableArgument( + OCFileListFragment.SEARCH_EVENT, + SearchEvent::class.java + ) != null + ) { + switchToSearchFragment(savedInstanceState) + } else { + createMinFragments(savedInstanceState) + } + + upgradeNotificationForInstantUpload() + checkOutdatedServer() + checkNotifications() + } + + /** + * For Android 7+. Opens a pop up info for the new instant upload and disabled the old instant upload. + */ + private fun upgradeNotificationForInstantUpload() { + // check for Android 6+ if legacy instant upload is activated --> disable + show info + if (preferences.instantPictureUploadEnabled() || preferences.instantVideoUploadEnabled()) { + preferences.removeLegacyPreferences() + // show info pop-up + MaterialAlertDialogBuilder(this, R.style.Theme_ownCloud_Dialog).setTitle(R.string.drawer_synced_folders) + .setMessage( + R.string.synced_folders_new_info + ).setPositiveButton( + R.string.drawer_open + ) { dialog: DialogInterface?, which: Int -> + // show instant upload + val syncedFoldersIntent = Intent(applicationContext, SyncedFoldersActivity::class.java) + dialog?.dismiss() + startActivity(syncedFoldersIntent) + }.setNegativeButton( + R.string.drawer_close + ) { dialog: DialogInterface?, which: Int -> dialog?.dismiss() } + .setIcon( + R.drawable.nav_synced_folders + ).show() + } + } + + private fun checkOutdatedServer() { + val user = getUser() + val optionalCapability = capabilities + + // show outdated warning + if (user.isPresent && + optionalCapability.isPresent && + CapabilityUtils.checkOutdatedWarning( + getResources(), + user.get().server.version, + optionalCapability.get().extendedSupport.isTrue, + optionalCapability.get().hasValidSubscription.isTrue + ) + ) { + DisplayUtils.showServerOutdatedSnackbar(this, Snackbar.LENGTH_LONG) + } + } + + private fun checkNotifications() { + lifecycleScope.launch(Dispatchers.IO) { + try { + val result = GetNotificationsRemoteOperation() + .execute(clientFactory.createNextcloudClient(accountManager.user)) + + if (result.isSuccess && result.getResultData()?.isEmpty() == false) { + runOnUiThread { mNotificationButton.visibility = View.VISIBLE } + } else { + runOnUiThread { mNotificationButton.visibility = View.GONE } + } + } catch (_: CreationException) { + Log_OC.e(TAG, "Could not fetch notifications!") + } + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + when (requestCode) { + // handle notification permission on API level >= 33 + PermissionUtil.PERMISSIONS_POST_NOTIFICATIONS -> + // dialogue was dismissed -> prompt for storage permissions + requestStoragePermissionIfNeeded(this) + + // If request is cancelled, result arrays are empty. + PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // permission was granted + EventBus.getDefault().post(TokenPushEvent()) + // toggle on is save since this is the only scenario this code gets accessed + } + + // If request is cancelled, result arrays are empty. + PermissionUtil.PERMISSIONS_CAMERA -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // permission was granted + getOCFileListFragmentFromFile(object : TransactionInterface { + override fun onOCFileListFragmentComplete(fragment: OCFileListFragment) { + fragment.directCameraUpload() + } + }) + } + + else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + } + + private fun switchToSearchFragment(savedInstanceState: Bundle?) { + if (savedInstanceState == null) { + val listOfFiles = OCFileListFragment() + val args = Bundle() + + args.putParcelable( + OCFileListFragment.SEARCH_EVENT, + intent + .getParcelableArgument( + OCFileListFragment.SEARCH_EVENT, + SearchEvent::class.java + ) + ) + args.putBoolean(OCFileListFragment.ARG_ALLOW_CONTEXTUAL_ACTIONS, true) + + listOfFiles.setArguments(args) + val transaction = supportFragmentManager.beginTransaction() + transaction.add(R.id.left_fragment_container, listOfFiles, TAG_LIST_OF_FILES) + transaction.commit() + } else { + supportFragmentManager.findFragmentByTag(TAG_LIST_OF_FILES) + } + } + + private fun createMinFragments(savedInstanceState: Bundle?) { + if (savedInstanceState == null) { + val listOfFiles = OCFileListFragment() + val args = Bundle() + args.putBoolean(OCFileListFragment.ARG_ALLOW_CONTEXTUAL_ACTIONS, true) + listOfFiles.setArguments(args) + val transaction = supportFragmentManager.beginTransaction() + transaction.add(R.id.left_fragment_container, listOfFiles, TAG_LIST_OF_FILES) + transaction.commit() + } else { + supportFragmentManager.findFragmentByTag(TAG_LIST_OF_FILES) + } + } + + private fun initFragments() { + // First fragment + val listOfFiles = this.listOfFilesFragment + if (listOfFiles != null && TextUtils.isEmpty(searchQuery)) { + listOfFiles.listDirectory(getCurrentDir(), file, MainApp.isOnlyOnDevice()) + } else { + Log_OC.e(TAG, "Still have a chance to lose the initialization of list fragment >(") + } + + // reset views + resetScrollingAndUpdateActionBar() + } + + // region Handle Intents + @SuppressLint("UnsafeIntentLaunch") + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleCommonIntents(intent) + handleSpecialIntents(intent) + handleRestartIntent(intent) + handleEcosystemIntent(intent) + } + + private fun handleSpecialIntents(intent: Intent) { + val action = intent.action + + when { + ACTION_DETAILS.equals(action, ignoreCase = true) -> { + val file = getFileFromIntent(intent) + setFile(file) + showDetails(file) + } + + Intent.ACTION_SEARCH == action -> handleSearchIntent(intent) + + ALL_FILES == action -> { + Log_OC.d(this, "Switch to oc file fragment") + + // Replace only if the fragment is NOT exactly OCFileListFragment + // Using `is OCFileListFragment` would also match subclasses, + // its needed because reinitializing OCFileListFragment itself causes an empty screen + leftFragment?.let { + if (it::class != OCFileListFragment::class) { + leftFragment = OCFileListFragment() + supportFragmentManager.executePendingTransactions() + } + } + + browseToRoot() + } + + LIST_GROUPFOLDERS == action -> { + Log_OC.d(this, "Switch to list groupfolders fragment") + leftFragment = GroupfolderListFragment() + supportFragmentManager.executePendingTransactions() + } + + ON_DEVICE == action -> { + refreshOrInitOCFileListFragment() + listOfFilesFragment?.setCurrentSearchType(SearchType.ON_DEVICE) + updateActionBarTitleAndHomeButton(null) + } + } + } + + @SuppressLint("UnsafeIntentLaunch") + private fun handleCommonIntents(intent: Intent) { + when (intent.action) { + Intent.ACTION_VIEW -> handleOpenFileViaIntent(intent) + + OPEN_FILE -> { + onOpenFileIntent(intent) + } + } + } + + @SuppressLint("UnsafeIntentLaunch") + private fun handleRestartIntent(intent: Intent) { + if (intent.action != RESTART) { + return + } + + finish() + startActivity(intent) + } + + private fun handleSearchIntent(intent: Intent) { + val searchEvent = intent.getParcelableArgument( + OCFileListFragment.SEARCH_EVENT, + SearchEvent::class.java + ) ?: return + + when (searchEvent.searchType) { + SearchRemoteOperation.SearchType.PHOTO_SEARCH -> { + Log_OC.d(this, "Switch to photo search fragment") + leftFragment = GalleryFragment().apply { + arguments = searchEvent.getBundle() + } + } + + SearchRemoteOperation.SearchType.SHARED_FILTER -> { + Log_OC.d(this, "Switch to shared fragment") + leftFragment = SharedListFragment().apply { + arguments = searchEvent.getBundle() + } + } + + else -> { + Log_OC.d(this, "Switch to oc file search fragment") + leftFragment = OCFileListFragment().apply { + arguments = searchEvent.getBundle() + } + } + } + + listOfFilesFragment?.isSearchFragment = true + listOfFilesFragment?.setCurrentSearchType(searchEvent) + } + // endregion + + private fun onOpenFileIntent(intent: Intent) { + val file = getFileFromIntent(intent) ?: run { + Log_OC.e(TAG, "Can't open file intent, file is null") + return + } + + // Ensure we have the correct fragment type + if (leftFragment !is OCFileListFragment || leftFragment is GalleryFragment) { + Log_OC.w( + TAG, + "Invalid fragment (${leftFragment?.let { it::class.simpleName } ?: "null"}). " + + "Replacing." + ) + setLeftFragment(OCFileListFragment(), false) + } + + // Ensure fragment is attached before interaction + Handler(Looper.getMainLooper()).post { + (supportFragmentManager.findFragmentByTag(TAG_LIST_OF_FILES) as? OCFileListFragment)?.let { fragment -> + leftFragment = fragment + setupHomeSearchToolbarWithSortAndListButtons() + fragment.onItemClicked(file) + } + } + } + + private fun setLeftFragment(fragment: Fragment?, showSortListGroup: Boolean) { + if (fragment == null) { + return + } + + prepareFragmentBeforeCommit(showSortListGroup) + commitFragment( + fragment, + object : CompletionCallback { + override fun onComplete(isFragmentCommitted: Boolean) { + Log_OC.d( + TAG, + "Left fragment committed: $isFragmentCommitted" + ) + } + } + ) + } + + private fun prepareFragmentBeforeCommit(showSortListGroup: Boolean) { + searchView?.post { searchView?.setQuery(searchQuery, true) } + setDrawerIndicatorEnabled(false) + + // clear the subtitle while navigating to any other screen from Media screen + clearToolbarSubtitle() + + showSortListGroup(showSortListGroup) + } + + private fun commitFragment(fragment: Fragment, callback: CompletionCallback) { + val fragmentManager = supportFragmentManager + if (this.isActive() && !fragmentManager.isDestroyed) { + val transaction = fragmentManager.beginTransaction() + transaction.addToBackStack(null) + transaction.replace(R.id.left_fragment_container, fragment, TAG_LIST_OF_FILES) + transaction.commit() + callback.onComplete(true) + } else { + callback.onComplete(false) + } + } + + private fun getOCFileListFragmentFromFile(transaction: TransactionInterface) { + val leftFragment = this.leftFragment + + if (leftFragment is OCFileListFragment) { + transaction.onOCFileListFragmentComplete(leftFragment) + return + } + + val listOfFiles = OCFileListFragment() + val args = Bundle() + args.putBoolean(OCFileListFragment.ARG_ALLOW_CONTEXTUAL_ACTIONS, true) + listOfFiles.setArguments(args) + + runOnUiThread { + val fm = supportFragmentManager + if (!fm.isStateSaved && !fm.isDestroyed) { + prepareFragmentBeforeCommit(true) + commitFragment( + listOfFiles, + object : CompletionCallback { + override fun onComplete(value: Boolean) { + if (value) { + Log_OC.d(TAG, "OCFileListFragment committed, executing pending transaction") + fm.executePendingTransactions() + transaction.onOCFileListFragmentComplete(listOfFiles) + } else { + Log_OC.d( + TAG, + "OCFileListFragment not committed, skipping executing " + + "pending transaction" + ) + } + } + } + ) + } + } + } + + fun showFileActions(file: OCFile?) { + dismissLoadingDialog() + getOCFileListFragmentFromFile(object : TransactionInterface { + override fun onOCFileListFragmentComplete(fragment: OCFileListFragment) { + browseUp(fragment) + fragment.onOverflowIconClicked(file, null) + } + }) + } + + var leftFragment: Fragment? + get() = supportFragmentManager.findFragmentByTag(TAG_LIST_OF_FILES) + + // Replaces the first fragment managed by the activity with the received as a parameter. + private set(fragment) { + setLeftFragment(fragment, true) + } + + val listOfFilesFragment: OCFileListFragment? + get() { + val listOfFiles = + supportFragmentManager.findFragmentByTag(TAG_LIST_OF_FILES) + if (listOfFiles is OCFileListFragment) { + return listOfFiles + } + Log_OC.e(TAG, "Access to unexisting list of files fragment") + return null + } + + protected fun resetScrollingAndUpdateActionBar() { + updateActionBarTitleAndHomeButton(file) + resetScrolling(true) + } + + fun updateListOfFilesFragment() { + val fileListFragment = this.listOfFilesFragment + fileListFragment?.listDirectory(MainApp.isOnlyOnDevice()) + } + + fun resetSearchView() { + val fileListFragment = this.listOfFilesFragment + fileListFragment?.isSearchFragment = false + } + + protected fun refreshDetailsFragmentIfVisible( + downloadEvent: String, + downloadedRemotePath: String, + success: Boolean + ) { + val leftFragment = this.leftFragment + if (leftFragment is FileDetailFragment) { + val waitedPreview = mWaitingToPreview != null && mWaitingToPreview?.remotePath == downloadedRemotePath + val fileInFragment = leftFragment.file + if (fileInFragment != null && downloadedRemotePath != fileInFragment.remotePath) { + // the user browsed to other file ; forget the automatic preview + mWaitingToPreview = null + } else if (downloadEvent == FileDownloadEventBroadcaster.ACTION_DOWNLOAD_ENQUEUED) { + // grant that the details fragment updates the progress bar + leftFragment.listenForTransferProgress() + leftFragment.updateFileDetails(true, false) + } else if (downloadEvent == FileDownloadEventBroadcaster.ACTION_DOWNLOAD_COMPLETED) { + // update the details panel + var detailsFragmentChanged = false + if (waitedPreview) { + if (success) { + // update the file from database, for the local storage path + mWaitingToPreview = mWaitingToPreview?.fileId?.let { storageManager.getFileById(it) } + + if (PreviewMediaActivity.Companion.canBePreviewed(mWaitingToPreview)) { + mWaitingToPreview?.let { + startMediaPreview(it, 0, true, true, true, true) + detailsFragmentChanged = true + } + } else if (MimeTypeUtil.isVCard(mWaitingToPreview?.mimeType)) { + startContactListFragment(mWaitingToPreview) + detailsFragmentChanged = true + } else if (PreviewTextFileFragment.canBePreviewed(mWaitingToPreview)) { + startTextPreview(mWaitingToPreview, true) + detailsFragmentChanged = true + } else if (MimeTypeUtil.isPDF(mWaitingToPreview)) { + mWaitingToPreview?.let { + startPdfPreview(it) + detailsFragmentChanged = true + } + } else { + fileOperationsHelper.openFile(mWaitingToPreview) + } + } + mWaitingToPreview = null + } + if (!detailsFragmentChanged) { + leftFragment.updateFileDetails(false, success) + } + } + } + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + if (mDrawerMenuItemstoShowHideList != null) { + val drawerOpen = isDrawerOpen + for (menuItem in mDrawerMenuItemstoShowHideList) { + menuItem.isVisible = !drawerOpen + } + } + + return super.onPrepareOptionsMenu(menu) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.activity_file_display, menu) + + menu.findItem(R.id.action_select_all).isVisible = false + val searchMenuItem = menu.findItem(R.id.action_search) + searchView = MenuItemCompat.getActionView(searchMenuItem) as SearchView? + searchMenuItem.isVisible = false + mSearchText.setOnClickListener { + showSearchView() + searchView?.postDelayed({ + searchView?.isIconified = false + searchView?.requestFocusFromTouch() + }, SEARCH_VIEW_FOCUS_DELAY) + } + + searchView?.let { viewThemeUtils.androidx.themeToolbarSearchView(it) } + + // populate list of menu items to show/hide when drawer is opened/closed + mDrawerMenuItemstoShowHideList = ArrayList(1) + mDrawerMenuItemstoShowHideList?.add(searchMenuItem) + + // focus the SearchView + if (!TextUtils.isEmpty(searchQuery)) { + searchView?.post { + searchView?.isIconified = false + searchView?.setQuery(searchQuery, true) + searchView?.clearFocus() + } + } + + val mSearchEditFrame = searchView?.findViewById(androidx.appcompat.R.id.search_edit_frame) + + searchView?.setOnCloseListener { + if (TextUtils.isEmpty(searchView?.query.toString())) { + searchView?.onActionViewCollapsed() + setDrawerIndicatorEnabled(isDrawerIndicatorAvailable) // order matters + supportActionBar?.setDisplayHomeAsUpEnabled(true) + mDrawerToggle.syncState() + + val ocFileListFragment = this.listOfFilesFragment + if (ocFileListFragment != null) { + ocFileListFragment.isSearchFragment = false + ocFileListFragment.refreshDirectory() + } + } else { + searchView?.post { searchView?.setQuery("", true) } + } + true + } + + val vto = mSearchEditFrame?.viewTreeObserver + vto?.addOnGlobalLayoutListener(object : OnGlobalLayoutListener { + var oldVisibility: Int = -1 + + override fun onGlobalLayout() { + val currentVisibility = mSearchEditFrame.visibility + + if (currentVisibility != oldVisibility) { + if (currentVisibility == View.VISIBLE) { + setDrawerIndicatorEnabled(false) + } + + oldVisibility = currentVisibility + } + } + }) + + return super.onCreateOptionsMenu(menu) + } + + private fun shouldOpenDrawer(): Boolean = !isDrawerOpen && + !isSearchOpen() && + isRoot(getCurrentDir()) && + this.leftFragment is OCFileListFragment + + /** + * Called, when the user selected something for uploading + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (data != null && + ( + requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS || + requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME + ) && + (resultCode == RESULT_OK || resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE) + ) { + requestUploadOfContentFromApps(requestCode, resultCode, data) + } else if (data != null && + requestCode == REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM && + ( + resultCode == RESULT_OK || + resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE || + resultCode == UploadFilesActivity.RESULT_OK_AND_DO_NOTHING || + resultCode == UploadFilesActivity.RESULT_OK_AND_DELETE + ) + ) { + requestUploadOfFilesFromFileSystem(data, resultCode) + } else if (( + requestCode == REQUEST_CODE__UPLOAD_FROM_CAMERA || + requestCode == REQUEST_CODE__UPLOAD_FROM_VIDEO_CAMERA + ) && + (resultCode == RESULT_OK || resultCode == UploadFilesActivity.RESULT_OK_AND_DELETE) + ) { + CheckAvailableSpaceTask( + object : CheckAvailableSpaceListener { + override fun onCheckAvailableSpaceStart() { + Log_OC.d(this, "onCheckAvailableSpaceStart") + } + + override fun onCheckAvailableSpaceFinish( + hasEnoughSpaceAvailable: Boolean, + vararg filesToUpload: String? + ) { + Log_OC.d(this, "onCheckAvailableSpaceFinish") + + if (hasEnoughSpaceAvailable) { + val file = File(filesToUpload[0]) + val renamedFile = if (requestCode == REQUEST_CODE__UPLOAD_FROM_CAMERA) { + File(file.parent + OCFile.PATH_SEPARATOR + FileOperationsHelper.getCapturedImageName()) + } else { + File(file.parent + OCFile.PATH_SEPARATOR + FileOperationsHelper.getCapturedVideoName()) + } + + if (!file.renameTo(renamedFile)) { + DisplayUtils.showSnackMessage( + this@FileDisplayActivity, + R.string.error_uploading_direct_camera_upload + ) + return + } + + requestUploadOfFilesFromFileSystem( + renamedFile.parentFile?.absolutePath, + arrayOf(renamedFile.absolutePath), + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE + ) + } + } + }, + *arrayOf( + FileOperationsHelper.createCameraFile( + this@FileDisplayActivity, + requestCode == REQUEST_CODE__UPLOAD_FROM_VIDEO_CAMERA + ).absolutePath + ) + ).execute() + } else if (requestCode == REQUEST_CODE__MOVE_OR_COPY_FILES && resultCode == RESULT_OK) { + exitSelectionMode() + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun exitSelectionMode() { + val ocFileListFragment = this.listOfFilesFragment + ocFileListFragment?.exitSelectionMode() + } + + private fun requestUploadOfFilesFromFileSystem(data: Intent, resultCode: Int) { + val filePaths = data.getStringArrayExtra(UploadFilesActivity.EXTRA_CHOSEN_FILES) ?: return + val basePath = data.getStringExtra(UploadFilesActivity.LOCAL_BASE_PATH) + requestUploadOfFilesFromFileSystem(basePath, filePaths, resultCode) + } + + private fun getRemotePaths(directory: String?, filePaths: Array, localBasePath: String): Array = + Array(filePaths.size) { j -> + val relativePath = StringUtils.removePrefix(filePaths[j], localBasePath) + (directory ?: "") + relativePath + } + + private fun requestUploadOfFilesFromFileSystem(localBasePath: String?, filePaths: Array, resultCode: Int) { + var localBasePath = localBasePath + if (localBasePath != null) { + if (!localBasePath.endsWith("/")) { + localBasePath = "$localBasePath/" + } + + val remotePathBase = getCurrentDir()?.remotePath + val decryptedRemotePaths = getRemotePaths(remotePathBase, filePaths, localBasePath) + + val behaviour = when (resultCode) { + UploadFilesActivity.RESULT_OK_AND_MOVE -> FileUploadWorker.LOCAL_BEHAVIOUR_MOVE + UploadFilesActivity.RESULT_OK_AND_DELETE -> FileUploadWorker.LOCAL_BEHAVIOUR_DELETE + else -> FileUploadWorker.LOCAL_BEHAVIOUR_FORGET + } + + connectivityService.isNetworkAndServerAvailable { result: Boolean? -> + if (result == true) { + val optionalCapabilities = capabilities + if (optionalCapabilities.isPresent) { + val isValidFolderPath = + remotePathBase?.let { + checkFolderPath( + it, + optionalCapabilities.get(), + this + ) + } + if (isValidFolderPath == false) { + DisplayUtils.showSnackMessage( + this, + R.string.file_name_validator_error_contains_reserved_names_or_invalid_characters + ) + return@isNetworkAndServerAvailable + } + + FileUploadHelper.instance().uploadNewFiles( + user.orElseThrow( + Supplier { RuntimeException() } + ), + filePaths, + decryptedRemotePaths, + behaviour, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.ASK_USER + ) + } + } else { + fileDataStorageManager.addCreateFileOfflineOperation(filePaths, decryptedRemotePaths) + } + } + } else { + Log_OC.d(TAG, "User clicked on 'Update' with no selection") + DisplayUtils.showSnackMessage(this, R.string.filedisplay_no_file_selected) + } + } + + private fun requestUploadOfContentFromApps(requestCode: Int, resultCode: Int, contentIntent: Intent) { + val streamsToUpload = ArrayList() + + if (contentIntent.clipData != null && (contentIntent.clipData?.itemCount ?: 0) > 0) { + for (i in 0..<(contentIntent.clipData?.itemCount ?: 0)) { + streamsToUpload.add(contentIntent.clipData?.getItemAt(i)?.uri) + } + } else { + streamsToUpload.add(contentIntent.data) + } + + val behaviour = + if (resultCode == + UploadFilesActivity.RESULT_OK_AND_MOVE + ) { + FileUploadWorker.LOCAL_BEHAVIOUR_MOVE + } else { + FileUploadWorker.LOCAL_BEHAVIOUR_COPY + } + + val currentDir = getCurrentDir() + val remotePath = if (currentDir != null) currentDir.remotePath else OCFile.ROOT_PATH + var fileDisplayNameTransformer: Function? = null + if (requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME) { + fileDisplayNameTransformer = { uri: Uri -> + val displayName = UriUtils.getDisplayNameForUri(uri, applicationContext) + if (displayName != null && displayName.isNotEmpty()) { + FileOperationsHelper.getTimestampedFileName("." + FilenameUtils.getExtension(displayName)) + } else { + null + } + } + } + + val uploader = UriUploader( + this, + streamsToUpload, + remotePath, + user.orElseThrow( + Supplier { RuntimeException() } + ), + behaviour, + false, // Not show waiting dialog while file is being copied from private storage + null, // Not needed copy temp task listener + fileDisplayNameTransformer + ) + + uploader.uploadUris() + } + + fun isSearchOpen(): Boolean { + if (searchView == null) { + return false + } else { + val mSearchEditFrame = searchView?.findViewById(androidx.appcompat.R.id.search_edit_frame) + return mSearchEditFrame != null && mSearchEditFrame.isVisible + } + } + + /** + * Sets up a custom back-press handler for this activity. + * + * This callback determines how the back button behaves based on the current UI state: + * - If the search view is open, it closes it. + * - If the navigation drawer is open, it closes it. + * - If the left fragment is an [OCFileListFragment]: + * - If in the root directory, it either navigates to "All Files" or finishes the activity. + * - Otherwise, it navigates one level up. + * - Otherwise, it pops the current fragment from the back stack. + * + * ### About `isEnabled` + * `isEnabled` is a property of [OnBackPressedCallback]. + * When `isEnabled = false`, this callback is **temporarily disabled**, + * allowing the system or other callbacks to handle the back press instead. + */ + private fun handleBackPress() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + handleBackPressImpl(before = { + isEnabled = false + }, after = { + isEnabled = true + }) + } + } + ) + } + + private fun handleBackPressImpl(before: () -> Unit = {}, after: () -> Unit = {}) { + when { + isSearchOpen() -> { + before() + resetSearchAction() + after() + } + + isDrawerOpen -> { + before() + closeDrawer() + after() + } + + leftFragment is OCFileListFragment -> { + before() + handleOCFileListFragmentBackPress() + after() + } + + else -> { + before() + popBack() + after() + } + } + } + + private fun handleOCFileListFragmentBackPress() { + val fragment = leftFragment as OCFileListFragment + + when { + // root + isRoot(getCurrentDir()) -> { + if (fragment.shouldNavigateBackToAllFiles()) { + navigateToAllFiles() + } else { + finish() + } + } + + // Normal folder navigation (go up) also works for shared tab + else -> { + browseUp(fragment) + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + when { + shouldOpenDrawer() -> openDrawer() + + else -> { + handleBackPressImpl() + } + } + true + } + + R.id.action_select_all -> { + listOfFilesFragment?.selectAllFiles(true) + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun browseUp(listOfFiles: OCFileListFragment) { + listOfFiles.onBrowseUp() + val currentFile = listOfFiles.currentFile + + file = currentFile + + currentFile?.let { + listOfFiles.setFabVisible(currentFile.canCreateFileAndFolder()) + listOfFiles.registerFabListener() + } + + resetScrollingAndUpdateActionBar() + startMetadataSyncForCurrentDir() + } + + private fun resetSearchAction() { + val leftFragment = this.leftFragment + if (!isSearchOpen() || searchView == null) { + return + } + + searchView?.clearFocus() + val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(searchView?.windowToken, 0) + searchView?.setQuery("", false) + searchView?.onActionViewCollapsed() + + if (isRoot(getCurrentDir()) && leftFragment is OCFileListFragment) { + // Remove the list to the original state + leftFragment.adapter?.let { adapter -> + val listOfHiddenFiles = adapter.listOfHiddenFiles + leftFragment.performSearch("", listOfHiddenFiles, true) + } + hideSearchView(getCurrentDir()) + setDrawerIndicatorEnabled(isDrawerIndicatorAvailable) + } + + if (leftFragment is UnifiedSearchFragment) { + showSortListGroup(false) + supportFragmentManager.popBackStack() + } + } + + /** + * Use this method when want to pop the fragment on back press. It resets Scrolling (See + * [with true][.resetScrolling] and pop the visibility for sortListGroup (See + * [with false][.showSortListGroup]. At last call to supportFragmentManager.popBackStack() + */ + private fun popBack() { + binding.fabMain.setImageResource(R.drawable.ic_plus) + resetScrolling(true) + showSortListGroup(false) + supportFragmentManager.popBackStack() + } + + override fun onSaveInstanceState(outState: Bundle) { + // responsibility of restore is preferred in onCreate() before than in + // onRestoreInstanceState when there are Fragments involved + super.onSaveInstanceState(outState) + mWaitingToPreview.logFileSize(TAG) + outState.putParcelable(KEY_WAITING_TO_PREVIEW, mWaitingToPreview) + outState.putBoolean(KEY_SYNC_IN_PROGRESS, mSyncInProgress) + // outState.putBoolean(FileDisplayActivity.KEY_REFRESH_SHARES_IN_PROGRESS, + // mRefreshSharesInProgress); + outState.putParcelable(KEY_WAITING_TO_SEND, mWaitingToSend) + if (searchView != null) { + outState.putBoolean(KEY_IS_SEARCH_OPEN, searchView?.isIconified == false) + } + outState.putString(KEY_SEARCH_QUERY, searchQuery) + outState.putBoolean(KEY_IS_SORT_GROUP_VISIBLE, sortListGroupVisibility()) + Log_OC.v(TAG, "onSaveInstanceState() end") + } + + override fun onResume() { + Log_OC.v(TAG, "onResume() start") + + super.onResume() + + folderRefreshScheduler.start() + + if (ocFileListFragment?.isSearchFragment == true) { + ocFileListFragment?.setSearchArgs(ocFileListFragment?.arguments) + } + highlightNavigationViewItem(menuItemId) + + if (SettingsActivity.isBackPressed) { + Log_OC.d(TAG, "User returned from settings activity, skipping reset content logic") + return + } + + isFileDisplayActivityResumed = true + + // Instead of onPostCreate, starting the loading in onResume for children fragments + val leftFragment = this.leftFragment + if (leftFragment !is OCFileListFragment) { + if (leftFragment is FileFragment) { + super.updateActionBarTitleAndHomeButton(leftFragment.file) + } + return + } + + if (isToolbarStyleSearch) { + setupHomeSearchToolbarWithSortAndListButtons() + } + val ocFileListFragment = leftFragment + syncAndUpdateFolder(ignoreETag = true, ignoreFocus = true) + + // Try to get the OCFile from the intent, if one was provided when launching this activity. + // 'file' comes from the FileActivity base class and represents the currently opened file or folder. + // We update it only when a valid file is found in the intent. + val startFile = intent?.let { getFileFromIntent(it) }?.also { + file = it + } + + // refresh list of files + if (searchView != null && !TextUtils.isEmpty(searchQuery)) { + searchView?.setQuery(searchQuery, false) + } else if (!ocFileListFragment.isSearchFragment && startFile == null) { + ocFileListFragment.listDirectory(MainApp.isOnlyOnDevice()) + ocFileListFragment.registerFabListener() + updateActionBarTitleAndHomeButton(currentDir) + } else { + ocFileListFragment.listDirectory(startFile, false) + updateActionBarTitleAndHomeButton(startFile) + } + + // show in-app review dialog to user + inAppReviewHelper.showInAppReview(this) + + checkNotifications() + + Log_OC.v(TAG, "onResume() end") + + Handler(Looper.getMainLooper()).postDelayed({ + isFileDisplayActivityResumed = false + }, ON_RESUMED_RESET_DELAY) + } + + private fun getFileFromIntent(intent: Intent?): OCFile? = + intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + ?: intent?.getStringExtra(EXTRA_FILE_REMOTE_PATH) + ?.let { fileDataStorageManager.getFileByDecryptedRemotePath(it) } + + // region local broadcast manager receivers + private fun registerReceivers() { + Log_OC.d(TAG, "registering receivers") + + localBroadcastManager.run { + val uploadFinishedIntent = IntentFilter(FileUploadEventBroadcaster.ACTION_UPLOAD_COMPLETED) + registerReceiver(fileUploadCompletedReceiver, uploadFinishedIntent) + + val folderDownloadStartedIntentFilter = + IntentFilter(FolderDownloadEventBroadcaster.ACTION_DOWNLOAD_ENQUEUED) + registerReceiver(folderDownloadStartedReceiver, folderDownloadStartedIntentFilter) + + val folderDownloadFinishedIntentFilter = + IntentFilter(FolderDownloadEventBroadcaster.ACTION_DOWNLOAD_COMPLETED) + registerReceiver(folderDownloadCompletedReceiver, folderDownloadFinishedIntentFilter) + + val fileDownloadStartedIntentFilter = IntentFilter(FileDownloadEventBroadcaster.ACTION_DOWNLOAD_ENQUEUED) + registerReceiver(fileDownloadStartedReceiver, fileDownloadStartedIntentFilter) + + val fileDownloadFinishedIntentFilter = IntentFilter(FileDownloadEventBroadcaster.ACTION_DOWNLOAD_COMPLETED) + registerReceiver(fileDownloadCompletedReceiver, fileDownloadFinishedIntentFilter) + + val syncBroadcastIntentFilter = IntentFilter(FileSyncAdapter.EVENT_FULL_SYNC_START).apply { + addAction(FileSyncAdapter.EVENT_FULL_SYNC_END) + addAction(FileSyncAdapter.EVENT_FULL_SYNC_FOLDER_CONTENTS_SYNCED) + addAction(RefreshFolderOperation.EVENT_SINGLE_FOLDER_CONTENTS_SYNCED) + addAction(RefreshFolderOperation.EVENT_SINGLE_FOLDER_SHARES_SYNCED) + } + registerReceiver(syncReceiver, syncBroadcastIntentFilter) + } + } + + private fun unregisterReceivers() { + Log_OC.d(TAG, "unregistering receivers") + + localBroadcastManager.run { + unregisterReceiver(syncReceiver) + unregisterReceiver(fileUploadCompletedReceiver) + unregisterReceiver(fileDownloadStartedReceiver) + unregisterReceiver(fileDownloadCompletedReceiver) + unregisterReceiver(folderDownloadStartedReceiver) + unregisterReceiver(folderDownloadCompletedReceiver) + } + } + // endregion + + override fun onStop() { + Log_OC.v(TAG, "onStop()") + folderRefreshScheduler.stop() + unregisterReceivers() + super.onStop() + } + + override fun onSortingOrderChosen(selection: FileSortOrder?) { + val ocFileListFragment = this.listOfFilesFragment + ocFileListFragment?.sortFiles(selection) + } + + override fun downloadFile(file: OCFile?, packageName: String?, activityName: String?) { + if (packageName != null && activityName != null) { + startDownloadForSending(file, OCFileListFragment.DOWNLOAD_SEND, packageName, activityName) + } + } + + // region SyncBroadcastReceiver + private inner class SyncReceiver : BroadcastReceiver() { + @SuppressLint("VisibleForTests") + override fun onReceive(context: Context?, intent: Intent) { + try { + val event = intent.action + Log_OC.d(TAG, "Received broadcast $event") + + // region EventData + val accountName = intent.getStringExtra(FileSyncAdapter.EXTRA_ACCOUNT_NAME) + val syncFolderRemotePath = intent.getStringExtra(FileSyncAdapter.EXTRA_FOLDER_PATH) + val id = intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT) + val syncResult = DataHolderUtil.getInstance().retrieve(id) + val sameAccount = + account != null && accountName != null && accountName == account.name && storageManager != null + val fileListFragment: OCFileListFragment? = this@FileDisplayActivity.listOfFilesFragment + + // endregion + if (sameAccount) { + handleSyncEvent(event, syncFolderRemotePath, id, fileListFragment, syncResult) + } + + if (syncResult is RemoteOperationResult<*> && + syncResult.code == RemoteOperationResult.ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED + ) { + mLastSslUntrustedServerResult = syncResult + } + } catch (_: java.lang.RuntimeException) { + safelyDeleteResult(intent) + } finally { + mSyncInProgress = false + } + } + } + + // avoid app crashes after changing the serial id of RemoteOperationResult in owncloud library + // with broadcast notifications pending to process + private fun safelyDeleteResult(intent: Intent) { + try { + DataHolderUtil.getInstance().delete(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT)) + } catch (_: java.lang.RuntimeException) { + Log_OC.i(TAG, "Ignoring error deleting data") + } + } + + private fun handleSyncEvent( + event: String?, + syncFolderRemotePath: String?, + id: String?, + fileListFragment: OCFileListFragment?, + syncResult: Any? + ) { + if (FileSyncAdapter.EVENT_FULL_SYNC_START == event) { + return + } + + var currentFile = file?.remotePath?.let { storageManager.getFileByPath(it) } + val currentDir = getCurrentDir()?.remotePath?.let { storageManager.getFileByPath(it) } + val isSyncFolderRemotePathRoot = OCFile.ROOT_PATH == syncFolderRemotePath + + if (currentDir == null && !isSyncFolderRemotePathRoot) { + handleRemovedFolder(syncFolderRemotePath) + } else if (currentDir != null) { + currentFile = handleRemovedFileFromServer(currentFile, currentDir) + updateFileList(fileListFragment, currentDir, syncFolderRemotePath) + file = currentFile + } + + handleSyncResult(event, syncResult) + DataHolderUtil.getInstance().delete(id) + handleScrollBehaviour(fileListFragment) + } + + private fun handleRemovedFileFromServer(currentFile: OCFile?, currentDir: OCFile?): OCFile? { + if (currentFile == null && file?.isFolder == false) { + resetScrollingAndUpdateActionBar() + return currentDir + } + + return currentFile + } + + private fun handleRemovedFolder(syncFolderRemotePath: String?) { + DisplayUtils.showSnackMessage(this, R.string.sync_current_folder_was_removed, syncFolderRemotePath) + fileListFragment?.let { + it.parentFolderFinder.getParentOnFirstParentRemoved(syncFolderRemotePath, storageManager)?.let { target -> + it.listDirectory(target, MainApp.isOnlyOnDevice()) + updateActionBarTitleAndHomeButton(target) + file = target + } + } + } + + private fun updateFileList( + ocFileListFragment: OCFileListFragment?, + currentDir: OCFile, + syncFolderRemotePath: String? + ) { + if (currentDir.remotePath != syncFolderRemotePath) { + return + } + + if (ocFileListFragment == null) { + return + } + + ocFileListFragment.listDirectory(currentDir, MainApp.isOnlyOnDevice()) + } + + private fun handleScrollBehaviour(ocFileListFragment: OCFileListFragment?) { + if (ocFileListFragment == null) { + return + } + + if (mSyncInProgress || ocFileListFragment.isLoading) { + return + } + + if (ocFileListFragment.isEmpty) { + lockScrolling() + return + } + + resetScrolling(false) + } + + private fun handleSyncResult(event: String?, syncResult: Any?) { + if (RefreshFolderOperation.EVENT_SINGLE_FOLDER_CONTENTS_SYNCED != event || syncResult == null) { + return + } + + if (syncResult is RemoteOperationResult<*> && syncResult.isSuccess) { + hideInfoBox() + return + } + + handleFailedSyncResult(syncResult) + } + + private fun handleFailedSyncResult(syncResult: Any?) { + if (checkForRemoteOperationError(syncResult)) { + requestCredentialsUpdate() + } else { + handleNonCredentialSyncErrors(syncResult) + } + } + + private fun handleNonCredentialSyncErrors(syncResult: Any?) { + if (syncResult !is RemoteOperationResult<*>) { + return + } + + when (syncResult.code) { + RemoteOperationResult.ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED -> showUntrustedCertDialog(syncResult) + RemoteOperationResult.ResultCode.MAINTENANCE_MODE -> showInfoBox(R.string.maintenance_mode) + RemoteOperationResult.ResultCode.NO_NETWORK_CONNECTION -> showInfoBox(R.string.offline_mode) + RemoteOperationResult.ResultCode.HOST_NOT_AVAILABLE -> showInfoBox(R.string.host_not_available) + RemoteOperationResult.ResultCode.SIGNING_TOS_NEEDED -> showTermsOfServiceDialog() + else -> {} + } + } + + private fun showTermsOfServiceDialog() { + if (supportFragmentManager.findFragmentByTag(DIALOG_TAG_SHOW_TOS) == null) { + TermsOfServiceDialog().show(supportFragmentManager, DIALOG_TAG_SHOW_TOS) + } + } + + private fun checkForRemoteOperationError(syncResult: Any?): Boolean { + if (syncResult !is RemoteOperationResult<*>) { + return false + } + + return RemoteOperationResult.ResultCode.UNAUTHORIZED == syncResult.code || + (syncResult.isException && syncResult.exception is AuthenticatorException) + } + + private fun setEmptyListState() { + listOfFilesFragment?.let { + when { + mSyncInProgress -> { + it.setEmptyListMessage(EmptyListState.LOADING) + } + + MainApp.isOnlyOnDevice() -> { + it.setEmptyListMessage(EmptyListState.ONLY_ON_DEVICE) + } + + else -> it.setEmptyListMessage(SearchType.NO_SEARCH) + } + } + } + + // endregion + + /** + * Once the file upload has finished -> update view + */ + private inner class FileUploadCompletedReceiver : BroadcastReceiver() { + private val tag = "UploadFinishReceiver" + + override fun onReceive(context: Context?, intent: Intent) { + Log_OC.d(tag, "upload finish received broadcast") + + val uploadedRemotePath = intent.getStringExtra(FileUploadEventBroadcaster.EXTRA_REMOTE_PATH) + val accountName = intent.getStringExtra(FileUploadEventBroadcaster.EXTRA_ACCOUNT_NAME) + val account = getAccount() + val sameAccount = accountName != null && account != null && accountName == account.name + val currentDir = getCurrentDir() + val isDescendant = + currentDir != null && uploadedRemotePath != null && uploadedRemotePath.startsWith(currentDir.remotePath) + + if (sameAccount && isDescendant) { + updateListOfFilesFragment() + } + + val uploadWasFine = intent.getBooleanExtra(FileUploadEventBroadcaster.EXTRA_UPLOAD_RESULT, false) + + var renamedInUpload = false + var sameFile = false + if (file != null) { + renamedInUpload = + file?.remotePath == intent.getStringExtra(FileUploadEventBroadcaster.EXTRA_OLD_REMOTE_PATH) + sameFile = file?.remotePath == uploadedRemotePath || renamedInUpload + } + + if (sameAccount && sameFile && this@FileDisplayActivity.leftFragment is FileDetailFragment) { + val fileDetailFragment = leftFragment as FileDetailFragment + if (uploadWasFine) { + file = storageManager.getFileByPath(uploadedRemotePath) + } else { + // TODO remove upload progress bar after upload failed. + Log_OC.d(TAG, "Remove upload progress bar after upload failed") + } + if (renamedInUpload && !uploadedRemotePath.isNullOrBlank()) { + val newName = File(uploadedRemotePath).name + DisplayUtils.showSnackMessage( + this@FileDisplayActivity, + R.string.filedetails_renamed_in_upload_msg, + newName + ) + } + + if (uploadWasFine || file?.fileExists() == true) { + fileDetailFragment.updateFileDetails(false, true) + } else { + onBackPressedDispatcher.onBackPressed() + } + + // Force the preview if the file is an image or text file + if (uploadWasFine) { + file?.let { + if (PreviewImageFragment.canBePreviewed(it)) { + startImagePreview(it, true) + } else if (PreviewTextFileFragment.canBePreviewed(it)) { + startTextPreview(it, true) + } + } + } + } + } + } + + private enum class FileDownloadIndicator(val iconId: Int) { + Downloading(R.drawable.ic_synchronizing), + Downloaded(R.drawable.ic_synced) + } + + private fun updateFileDownloadIndicator(state: FileDownloadIndicator, file: OCFile) { + ocFileListFragment?.adapter?.updateFileIndicator(state.iconId, file) + } + + // region FolderDownloadWorker receivers + @Suppress("ReturnCount") + private fun getFolderFromFolderDownloadWorker(intent: Intent): OCFile? { + val id = intent.getLongExtra(FolderDownloadEventBroadcaster.EXTRA_FILE_ID, -1L) + if (id == -1L) { + Log_OC.e(TAG, "invalid id received") + return null + } + + val folder = storageManager.getFileById(id) + if (folder == null) { + Log_OC.e(TAG, "folder not exists") + return null + } + + return folder + } + + private inner class FolderDownloadStartedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log_OC.d(TAG, "download worker started") + val folder = getFolderFromFolderDownloadWorker(intent) ?: return + updateFileDownloadIndicator(FileDownloadIndicator.Downloading, folder) + } + } + + private inner class FolderDownloadCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log_OC.d(TAG, "download worker finished") + val folder = getFolderFromFolderDownloadWorker(intent) ?: return + updateFileDownloadIndicator(FileDownloadIndicator.Downloaded, folder) + } + } + // endregion + + // region FileDownloadWorker receivers + private fun getFileFromFileDownloadWorker(intent: Intent): OCFile? { + val remotePath = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_REMOTE_PATH) + val file = fileDataStorageManager.getFileByDecryptedRemotePath(remotePath) ?: return null + return file + } + + private inner class FileDownloadStartedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log_OC.d(TAG, "download worker started") + getFileFromFileDownloadWorker(intent)?.let { + updateFileDownloadIndicator(FileDownloadIndicator.Downloading, it) + } + handleDownloadWorkerState() + } + } + + private inner class FileDownloadCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + Log_OC.d(TAG, "file download completed received") + getFileFromFileDownloadWorker(intent)?.let { + updateFileDownloadIndicator(FileDownloadIndicator.Downloaded, it) + } + + fileDownloadProgressListener = null + + if (fileIDForImmediatePreview == -1L) { + Log_OC.d(TAG, "updating ui for file download") + updateUIForFileDownload(intent) + return + } + + Log_OC.d(TAG, "updating ui immediate file preview") + + val downloadedRemotePath = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_REMOTE_PATH) + val currentFile = storageManager.getFileByDecryptedRemotePath(downloadedRemotePath) ?: return + if (fileIDForImmediatePreview != currentFile.fileId || !currentFile.isDown) { + return + } + + fileIDForImmediatePreview = -1 + if (PreviewImageFragment.canBePreviewed(currentFile)) { + startImagePreview(currentFile, currentFile.isDown) + } else { + previewFile(currentFile, null) + } + } + + private fun updateUIForFileDownload(intent: Intent) { + val sameAccount = isSameAccount(intent) + val downloadedRemotePath = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_REMOTE_PATH) + val downloadBehaviour = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_DOWNLOAD_BEHAVIOUR) + val isDescendant = isDescendant(downloadedRemotePath) + + if (sameAccount && isDescendant) { + updateListOfFilesFragment() + val intentAction = intent.action + if (intentAction != null && downloadedRemotePath != null) { + refreshDetailsFragmentIfVisible( + intentAction, + downloadedRemotePath, + intent.getBooleanExtra(FileDownloadEventBroadcaster.EXTRA_DOWNLOAD_RESULT, false) + ) + } + } + + if (mWaitingToSend != null) { + // update file after downloading + mWaitingToSend = storageManager.getFileByRemoteId(mWaitingToSend?.remoteId) + if (mWaitingToSend != null && + mWaitingToSend?.isDown == true && + OCFileListFragment.DOWNLOAD_SEND == downloadBehaviour + ) { + val packageName = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_PACKAGE_NAME) ?: return + val activityName = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_ACTIVITY_NAME) ?: return + sendDownloadedFile(packageName, activityName) + } + } + + if (mWaitingToPreview != null) { + mWaitingToPreview = storageManager.getFileByRemoteId(mWaitingToPreview?.remoteId) + if (mWaitingToPreview != null && + mWaitingToPreview?.isDown == true && + EditImageActivity.OPEN_IMAGE_EDITOR == downloadBehaviour + ) { + mWaitingToPreview?.let { + startImageEditor(it) + } + } + } + } + + fun isDescendant(downloadedRemotePath: String?): Boolean { + val currentDir = getCurrentDir() + return currentDir != null && + downloadedRemotePath != null && + downloadedRemotePath.startsWith(currentDir.remotePath) + } + + fun isSameAccount(intent: Intent): Boolean { + val accountName = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_ACCOUNT_NAME) + return accountName != null && account != null && accountName == account.name + } + } + // endregion + + fun browseToRoot() { + listOfFilesFragment?.let { + val root = storageManager.getFileByPath(OCFile.ROOT_PATH) + it.resetSearchAttributes() + file = it.currentFile + startSyncFolderOperation(root, false) + } + + binding.fabMain.setImageResource(R.drawable.ic_plus) + resetScrollingAndUpdateActionBar() + } + + override fun onBrowsedDownTo(directory: OCFile?) { + file = directory + resetScrollingAndUpdateActionBar() + startSyncFolderOperation(directory, false) + startMetadataSyncForCurrentDir() + } + + /** + * Shows the information of the [OCFile] received as a parameter. + * + * @param file [OCFile] whose details will be shown + */ + override fun showDetails(file: OCFile?) { + showDetails(file, 0) + } + + /** + * Shows the information of the [OCFile] received as a parameter. + * + * @param file [OCFile] whose details will be shown + * @param activeTab the active tab in the details view + */ + override fun showDetails(file: OCFile?, activeTab: Int) { + val currentUser = user.orElseThrow(Supplier { RuntimeException() }) + + resetScrolling(true) + + val detailFragment: Fragment = FileDetailFragment.newInstance(file, currentUser, activeTab) + setLeftFragment(detailFragment, false) + configureToolbarForPreview(file) + } + + /** + * Prevents content scrolling and toolbar collapse + */ + @VisibleForTesting + fun lockScrolling() { + binding.appbar.appbar.setExpanded(true, false) + val appbarParams = binding.appbar.toolbarFrame.layoutParams as AppBarLayout.LayoutParams + appbarParams.setScrollFlags(AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL) + binding.appbar.toolbarFrame.layoutParams = appbarParams + } + + /** + * Resets content scrolling and toolbar collapse + */ + @VisibleForTesting + fun resetScrolling(expandAppBar: Boolean) { + val appbarParams = binding.appbar.toolbarFrame.layoutParams as AppBarLayout.LayoutParams + appbarParams.setScrollFlags( + AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS + ) + binding.appbar.toolbarFrame.layoutParams = appbarParams + if (expandAppBar) { + binding.appbar.appbar.setExpanded(true, false) + } + } + + public override fun updateActionBarTitleAndHomeButton(chosenFile: OCFile?) { + var chosenFile = chosenFile + if (chosenFile == null) { + chosenFile = file // if no file is passed, current file decides + } + super.updateActionBarTitleAndHomeButton(chosenFile) + } + + override fun isDrawerIndicatorAvailable(): Boolean = isRoot(getCurrentDir()) + + private fun observeWorkerState() { + observeWorker { state -> + when (state) { + is OfflineOperationsCompleted -> { + refreshCurrentDirectory() + } + + else -> Unit + } + } + } + + fun previewImageWithSearchContext(file: OCFile, searchFragment: Boolean, currentSearchType: SearchType?) { + val type = if (searchFragment) { + when (currentSearchType) { + SearchType.FAVORITE_SEARCH -> VirtualFolderType.FAVORITE + SearchType.GALLERY_SEARCH -> VirtualFolderType.GALLERY + else -> VirtualFolderType.NONE + } + } else { + null + } + + startImagePreview(file, file.isDown, type) + } + + fun previewFile(file: OCFile, setFabVisible: CompletionCallback?) { + if (!file.isDown) { + Log_OC.d(TAG, "File is not downloaded, cannot be previewed") + return + } + + if (MimeTypeUtil.isVCard(file)) { + startContactListFragment(file) + } else if (MimeTypeUtil.isPDF(file)) { + startPdfPreview(file) + } else if (PreviewTextFileFragment.canBePreviewed(file)) { + setFabVisible?.onComplete(false) + startTextPreview(file, false) + } else if (PreviewMediaActivity.Companion.canBePreviewed(file)) { + setFabVisible?.onComplete(false) + startMediaPreview(file, 0, true, true, false, true) + } else { + fileOperationsHelper.openFile(file) + } + } + + fun refreshCurrentDirectory() { + val currentDir = + if (getCurrentDir() != + null + ) { + storageManager.getFileByDecryptedRemotePath(getCurrentDir()?.remotePath) + } else { + null + } + + val lastFragment = lastFragment() + + var fileListFragment: OCFileListFragment? = null + if (lastFragment is OCFileListFragment) { + fileListFragment = lastFragment + } + if (fileListFragment == null) { + fileListFragment = listOfFilesFragment + } + fileListFragment?.listDirectory(currentDir, MainApp.isOnlyOnDevice()) + } + + private fun handleDownloadWorkerState() { + if (mWaitingToPreview != null && storageManager != null) { + mWaitingToPreview = mWaitingToPreview?.fileId?.let { storageManager.getFileById(it) } + if (mWaitingToPreview != null && mWaitingToPreview?.isDown == false) { + requestForDownload() + } + } + } + + override fun newTransferenceServiceConnection(): ServiceConnection = ListServiceConnection() + + /** + * Defines callbacks for service binding, passed to bindService() + * TODO: Check if this can be removed since download and uploads uses work manager now. + */ + private inner class ListServiceConnection : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) = Unit + + override fun onServiceDisconnected(component: ComponentName) { + if (component == ComponentName(this@FileDisplayActivity, FileDownloadWorker::class.java)) { + Log_OC.d(TAG, "Download service disconnected") + fileDownloadProgressListener = null + } + } + } + + /** + * Updates the view associated to the activity after the finish of some operation over files in the current + * account. + * + * @param operation Removal operation performed. + * @param result Result of the removal. + */ + override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>) { + super.onRemoteOperationFinish(operation, result) + + when (operation) { + is RemoveFileOperation -> { + onRemoveFileOperationFinish(operation, result) + } + + is RenameFileOperation -> { + onRenameFileOperationFinish(operation, result) + } + + is SynchronizeFileOperation -> { + onSynchronizeFileOperationFinish(operation, result) + } + + is CreateFolderOperation -> { + onCreateFolderOperationFinish(operation, result) + } + + is MoveFileOperation -> { + onMoveFileOperationFinish(operation, result) + } + + is CopyFileOperation -> { + onCopyFileOperationFinish(operation, result) + } + + is RestoreFileVersionRemoteOperation -> { + onRestoreFileVersionOperationFinish(result) + } + } + } + + private val fileListFragment: OCFileListFragment? + get() = if (lastFragment() is OCFileListFragment) lastFragment() as OCFileListFragment else listOfFilesFragment + + private fun refreshGalleryFragmentIfNeeded() { + val fileListFragment = this.fileListFragment + if (fileListFragment is GalleryFragment) { + startPhotoSearch(R.id.nav_gallery) + } + } + + private fun refreshShowDetails() { + val details = this.leftFragment + if (details is FileFragment) { + var file = details.file + if (file != null) { + file = storageManager.getFileByPath(file.remotePath) + if (details is PreviewTextFragment) { + // Refresh OCFile of the fragment + (details as PreviewTextFileFragment).updateFile(file) + } else { + showDetails(file) + } + } + supportInvalidateOptionsMenu() + } + } + + val deleteBatchTracker = DeleteBatchTracker(onAllDeletesFinished = { + if (leftFragment is GalleryFragment) { + val galleryFragment = leftFragment as GalleryFragment + galleryFragment.onRefresh() + } + }) + + /** + * Updates the view associated to the activity after the finish of an operation trying to remove a file. + * + * @param operation Removal operation performed. + * @param result Result of the removal. + */ + private fun onRemoveFileOperationFinish(operation: RemoveFileOperation, result: RemoteOperationResult<*>) { + deleteBatchTracker.onSingleDeleteFinished() + + if (result.isSuccess) { + val removedFile = operation.file + tryStopPlaying(removedFile) + val leftFragment = this.leftFragment + + // check if file is still available, if so do nothing + val fileAvailable = storageManager.fileExists(removedFile.fileId) + if (leftFragment is FileFragment && !fileAvailable && removedFile == leftFragment.file) { + file = storageManager.getFileById(removedFile.parentId) + resetScrollingAndUpdateActionBar() + } + val parentFile = storageManager.getFileById(removedFile.parentId) + if (parentFile != null && parentFile == getCurrentDir()) { + updateListOfFilesFragment() + } else if (leftFragment is OCFileListFragment && + SearchRemoteOperation.SearchType.FAVORITE_SEARCH == leftFragment.searchEvent?.searchType + ) { + leftFragment.adapter?.run { + val file = files.find { it.fileId == removedFile.fileId } + if (file != null) { + val pos = getItemPosition(file) + files.remove(file) + notifyItemRemoved(pos) + } + } + } + supportInvalidateOptionsMenu() + fetchRecommendedFilesIfNeeded(ignoreETag = true, currentDir) + } else { + if (result.isSslRecoverableException) { + mLastSslUntrustedServerResult = result + showUntrustedCertDialog(mLastSslUntrustedServerResult) + } + } + } + + override fun onAutoUploadFolderRemoved( + entities: List, + filesToRemove: List, + onlyLocalCopy: Boolean + ) { + val dialog = ConfirmationDialogFragment.newInstance( + messageResId = R.string.auto_upload_delete_dialog_description, + messageArguments = null, + titleResId = R.string.auto_upload_delete_dialog_title, + titleIconId = R.drawable.ic_info, + positiveButtonTextId = R.string.common_delete, + negativeButtonTextId = R.string.common_cancel, + neutralButtonTextId = -1 + ) + + dialog.setOnConfirmationListener(object : ConfirmationDialogFragment.ConfirmationDialogFragmentListener { + override fun onConfirmation(callerTag: String?) { + lifecycleScope.launch(Dispatchers.IO) { + entities.forEach { entity -> + entity.id?.toLong()?.let { + fileUploadHelper.removeEntityFromUploadEntities(it) + syncedFolderProvider.deleteSyncedFolder(it) + } + } + + withContext(Dispatchers.Main) { + connectivityService.isNetworkAndServerAvailable { isAvailable -> + if (isAvailable) { + fileOperationsHelper?.removeFiles( + filesToRemove, + onlyLocalCopy, + true + ) + } else { + if (onlyLocalCopy) { + fileOperationsHelper?.removeFiles(filesToRemove, true, true) + } else { + filesToRemove.forEach { file -> + fileDataStorageManager.addRemoveFileOfflineOperation(file) + } + } + } + onFilesRemoved() + } + } + } + } + + override fun onNeutral(callerTag: String?) = Unit + override fun onCancel(callerTag: String?) = Unit + }) + + if (isDialogFragmentReady(dialog)) { + dialog.show(supportFragmentManager, null) + } + } + + private fun onRestoreFileVersionOperationFinish(result: RemoteOperationResult<*>) { + if (result.isSuccess) { + val file = getFile() + + // delete old local copy + if (file?.isDown == true) { + val list: MutableList = ArrayList() + list.add(file) + fileOperationsHelper.removeFiles(list, true, true) + + // download new version, only if file was previously download + showSyncLoadingDialog(file.isFolder == true) + fileOperationsHelper.syncFile(file) + } + + val parent = file?.let { storageManager.getFileById(it.parentId) } + startSyncFolderOperation(parent, ignoreETag = true, ignoreFocus = true) + + val leftFragment = this.leftFragment + if (leftFragment is FileDetailFragment) { + leftFragment.getFileDetailActivitiesFragment().reload() + } + } else { + DisplayUtils.showSnackMessage(this, R.string.file_version_restored_error) + } + } + + private fun tryStopPlaying(file: OCFile) { + // placeholder for stop-on-delete future code + if (mPlayerConnection != null && MimeTypeUtil.isAudio(file) && mPlayerConnection?.isPlaying() == true) { + mPlayerConnection?.stop(file) + } + } + + /** + * Updates the view associated to the activity after the finish of an operation trying to move a file. + * + * @param operation Move operation performed. + * @param result Result of the move operation. + */ + private fun onMoveFileOperationFinish(operation: MoveFileOperation?, result: RemoteOperationResult<*>) { + if (!result.isSuccess) { + try { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + } catch (e: Resources.NotFoundException) { + Log_OC.e(TAG, "Error while trying to show fail message ", e) + } + } + } + + /** + * Updates the view associated to the activity after the finish of an operation trying to copy a file. + * + * @param operation Copy operation performed. + * @param result Result of the copy operation. + */ + private fun onCopyFileOperationFinish(operation: CopyFileOperation?, result: RemoteOperationResult<*>) { + if (result.isSuccess) { + updateListOfFilesFragment() + refreshGalleryFragmentIfNeeded() + } else { + try { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + } catch (e: Resources.NotFoundException) { + Log_OC.e(TAG, "Error while trying to show fail message ", e) + } + } + } + + /** + * Updates the view associated to the activity after the finish of an operation trying to rename a file. + * + * @param operation Renaming operation performed. + * @param result Result of the renaming. + */ + private fun onRenameFileOperationFinish(operation: RenameFileOperation, result: RemoteOperationResult<*>) { + val optionalUser = user + val renamedFile = operation.file + if (result.isSuccess && optionalUser.isPresent) { + val currentUser = optionalUser.get() + val leftFragment = this.leftFragment + if (leftFragment is FileFragment) { + if (leftFragment is FileDetailFragment && renamedFile == leftFragment.file) { + leftFragment.updateFileDetails(renamedFile, currentUser) + showDetails(renamedFile) + } else if (leftFragment is PreviewMediaFragment && renamedFile == leftFragment.file) { + leftFragment.updateFile(renamedFile) + if (PreviewMediaFragment.canBePreviewed(renamedFile)) { + val position = leftFragment.position + startMediaPreview(renamedFile, position, true, true, true, false) + } else { + fileOperationsHelper.openFile(renamedFile) + } + } else if (leftFragment is PreviewTextFragment && renamedFile == leftFragment.file) { + (leftFragment as PreviewTextFileFragment).updateFile(renamedFile) + if (PreviewTextFileFragment.canBePreviewed(renamedFile)) { + startTextPreview(renamedFile, true) + } else { + fileOperationsHelper.openFile(renamedFile) + } + } + } + + val file = storageManager.getFileById(renamedFile.parentId) + if (file != null && file == getCurrentDir()) { + updateListOfFilesFragment() + } + refreshGalleryFragmentIfNeeded() + fetchRecommendedFilesIfNeeded(ignoreETag = true, currentDir) + } else { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + + if (result.isSslRecoverableException) { + mLastSslUntrustedServerResult = result + showUntrustedCertDialog(mLastSslUntrustedServerResult) + } + } + } + + private fun onSynchronizeFileOperationFinish( + operation: SynchronizeFileOperation, + result: RemoteOperationResult<*> + ) { + if (result.isSuccess && operation.transferWasRequested()) { + val syncedFile = operation.localFile + onTransferStateChanged(syncedFile, true, true) + supportInvalidateOptionsMenu() + refreshShowDetails() + } + } + + /** + * Updates the view associated to the activity after the finish of an operation trying create a new folder + * + * @param operation Creation operation performed. + * @param result Result of the creation. + */ + private fun onCreateFolderOperationFinish(operation: CreateFolderOperation, result: RemoteOperationResult<*>) { + if (result.isSuccess) { + val fileListFragment = this.listOfFilesFragment + if (operation.shouldEncrypt()) { + val file = storageManager.getFileByDecryptedRemotePath(operation.remotePath) + if (file == null) { + Log_OC.e( + TAG, + "onCreateFolderOperationFinish(): file not saved after create folder operation, cannot encrypt" + ) + return + } + fileOperationsHelper.toggleEncryption(file, true) + return + } + + fileListFragment?.onItemClicked(storageManager.getFileByDecryptedRemotePath(operation.getRemotePath())) + } else { + try { + if (RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS == result.code) { + DisplayUtils.showSnackMessage(this, R.string.folder_already_exists) + } else { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + } + } catch (e: Resources.NotFoundException) { + Log_OC.e(TAG, "Error while trying to show fail message ", e) + } + } + } + + /** + * {@inheritDoc} + */ + override fun onTransferStateChanged(file: OCFile, downloading: Boolean, uploading: Boolean) { + updateListOfFilesFragment() + val leftFragment = this.leftFragment + val optionalUser = user + if (leftFragment is FileDetailFragment && file == leftFragment.file && optionalUser.isPresent) { + val currentUser = optionalUser.get() + if (downloading || uploading) { + leftFragment.updateFileDetails(file, currentUser) + } else { + if (!file.fileExists()) { + resetScrollingAndUpdateActionBar() + } else { + leftFragment.updateFileDetails(false, true) + } + } + } + } + + private fun requestForDownload() { + val user = user.orElseThrow(Supplier { RuntimeException() }) + mWaitingToPreview?.let { + FileDownloadHelper.instance().downloadFileIfNotStartedBefore(user, it) + } + } + + override fun onSavedCertificate() { + startSyncFolderOperation(getCurrentDir(), false) + } + + /** + * Starts an operation to refresh the requested folder. + * + * + * The operation is run in a new background thread created on the fly. + * + * + * The refresh updates is a "light sync": properties of regular files in folder are updated (including associated + * shares), but not their contents. Only the contents of files marked to be kept-in-sync are synchronized too. + * + * @param folder Folder to refresh. + * @param ignoreETag If 'true', the data from the server will be fetched and sync'ed even if the eTag didn't + * change. + * @param ignoreFocus reloads file list even without focus, e.g. on tablet mode, focus can still be in detail view + */ + @JvmOverloads + fun startSyncFolderOperation(folder: OCFile?, ignoreETag: Boolean, ignoreFocus: Boolean = false) { + Log_OC.d(TAG, "startSyncFolderOperation called, ignoreEtag: $ignoreETag, ignoreFocus: $ignoreFocus") + + // the execution is slightly delayed to allow the activity get the window focus if it's being started + // or if the method is called from a dialog that is being dismissed + + if (TextUtils.isEmpty(searchQuery) && user.isPresent) { + mSyncInProgress = true + + handler.postDelayed({ + val user = getUser() + if ((!ignoreFocus && !hasWindowFocus()) || !user.isPresent) { + // do not refresh if the user rotates the device while another window has focus + // or if the current user is no longer valid + mSyncInProgress = false + return@postDelayed + } + + val currentSyncTime = System.currentTimeMillis() + + val operation = RefreshFolderOperation( + folder, + currentSyncTime, + false, + ignoreETag, + storageManager, + user.get(), + applicationContext + ) + operation.execute( + account, + MainApp.getAppContext(), + this@FileDisplayActivity, + null, + null + ) + + fetchRecommendedFilesIfNeeded(ignoreETag, folder) + }, DELAY_TO_REQUEST_REFRESH_OPERATION_LATER) + } + } + + private fun fetchRecommendedFilesIfNeeded(ignoreETag: Boolean, folder: OCFile?) { + val optionalCapabilities = capabilities + if (optionalCapabilities.isEmpty) { + return + } + + if (folder?.isRootDirectory == false || optionalCapabilities.get().recommendations.isFalse) { + return + } + + if (user.isPresent) { + val accountName = user.get().accountName + val fragment = this.listOfFilesFragment + lifecycleScope.launch(Dispatchers.IO) { + val recommendedFiles = filesRepository.fetchRecommendedFiles(accountName, ignoreETag, storageManager) + withContext(Dispatchers.Main) { + fragment?.adapter?.updateRecommendedFiles(recommendedFiles) + } + } + } + } + + private fun requestForDownload(file: OCFile, downloadBehaviour: String, packageName: String, activityName: String) { + val currentUser = user.orElseThrow(Supplier { RuntimeException() }) + if (!FileDownloadHelper.instance().isDownloading(currentUser, file)) { + FileDownloadHelper.instance().downloadFile( + currentUser, + file, + downloadBehaviour, + DownloadType.DOWNLOAD, + activityName, + packageName, + null + ) + } + } + + private fun sendDownloadedFile(packageName: String, activityName: String) { + val waitingToSend = mWaitingToSend + if (waitingToSend != null) { + val sendIntent = IntentUtil.createSendIntent(this, waitingToSend) + sendIntent.component = ComponentName(packageName, activityName) + + // Show dialog + val sendTitle = getString(R.string.activity_chooser_send_file_title) + startActivity(Intent.createChooser(sendIntent, sendTitle)) + } else { + Log_OC.e(TAG, "Trying to send a NULL OCFile") + } + + mWaitingToSend = null + } + + /** + * Requests the download of the received [OCFile] , updates the UI to monitor the download progress and + * prepares the activity to send the file when the download finishes. + * + * @param file [OCFile] to download and preview. + * @param packageName + * @param activityName + */ + fun startDownloadForSending(file: OCFile?, downloadBehaviour: String, packageName: String, activityName: String) { + mWaitingToSend = file + mWaitingToSend?.let { + requestForDownload(it, downloadBehaviour, packageName, activityName) + } + } + + fun startImagePreview(file: OCFile, showPreview: Boolean, type: VirtualFolderType? = null) { + if (user.isEmpty) { + Log_OC.e(TAG, "cannot start image preview") + return + } + + val intent = Intent(this, PreviewImageActivity::class.java).apply { + putExtra(EXTRA_FILE, file) + putExtra(EXTRA_LIVE_PHOTO_FILE, file.livePhotoVideo) + putExtra(EXTRA_USER, user.get()) + type?.let { putExtra(PreviewImageActivity.EXTRA_VIRTUAL_TYPE, it) } + } + + if (showPreview) { + startActivity(intent) + } else { + val helper = FileOperationsHelper( + this, + userAccountManager, + connectivityService, + editorUtils + ) + helper.startSyncForFileAndIntent(file, intent) + } + } + + /** + * Stars the preview of an already down media [OCFile]. + * + * @param file Media [OCFile] to preview. + * @param startPlaybackPosition Media position where the playback will be started, in milliseconds. + * @param autoplay When 'true', the playback will start without user interactions. + */ + fun startMediaPreview( + file: OCFile, + startPlaybackPosition: Long, + autoplay: Boolean, + showPreview: Boolean, + streamMedia: Boolean, + showInActivity: Boolean + ) { + val user = getUser() + if (!user.isPresent) { + return // not reachable under normal conditions + } + val actualUser = user.get() + if ((showPreview && file.isDown && !file.isDownloading) || streamMedia) { + if (showInActivity) { + startMediaActivity(file, startPlaybackPosition, autoplay, actualUser) + } else { + configureToolbarForPreview(file) + val mediaFragment: Fragment = newInstance(file, user.get(), startPlaybackPosition, autoplay, false) + setLeftFragment(mediaFragment, false) + } + } else { + val previewIntent = Intent() + previewIntent.putExtra(EXTRA_FILE, file) + previewIntent.putExtra(PreviewMediaFragment.EXTRA_START_POSITION, startPlaybackPosition) + previewIntent.putExtra(PreviewMediaFragment.EXTRA_AUTOPLAY, autoplay) + val fileOperationsHelper = + FileOperationsHelper(this, userAccountManager, connectivityService, editorUtils) + fileOperationsHelper.startSyncForFileAndIntent(file, previewIntent) + } + } + + private fun startMediaActivity(file: OCFile?, startPlaybackPosition: Long, autoplay: Boolean, user: User?) { + val previewMediaIntent = Intent(this, PreviewMediaActivity::class.java) + previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_FILE, file) + + // Safely handle the absence of a user + if (user != null) { + previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_USER, user) + } + + previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_START_POSITION, startPlaybackPosition) + previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_AUTOPLAY, autoplay) + startActivity(previewMediaIntent) + } + + fun configureToolbarForPreview(file: OCFile?) { + lockScrolling() + updateActionBarForFile(file) + } + + /** + * Starts the preview of a text file [OCFile]. + * + * @param file Text [OCFile] to preview. + */ + fun startTextPreview(file: OCFile?, showPreview: Boolean) { + val optUser = user + if (!optUser.isPresent) { + // remnants of old unsafe system; do not crash, silently stop + return + } + val user = optUser.get() + if (showPreview) { + val fragment = PreviewTextFileFragment.create(user, file, searchOpen, searchQuery) + setLeftFragment(fragment, false) + configureToolbarForPreview(file) + } else { + val previewIntent = Intent() + previewIntent.putExtra(EXTRA_FILE, file) + previewIntent.putExtra(TEXT_PREVIEW, true) + val fileOperationsHelper = + FileOperationsHelper(this, userAccountManager, connectivityService, editorUtils) + fileOperationsHelper.startSyncForFileAndIntent(file, previewIntent) + } + } + + /** + * Starts rich workspace preview for a folder. + * + * @param folder [OCFile] to preview its rich workspace. + */ + fun startRichWorkspacePreview(folder: OCFile?) { + val args = Bundle() + args.putParcelable(EXTRA_FILE, folder) + configureToolbarForPreview(folder) + val textPreviewFragment = + Fragment.instantiate(applicationContext, PreviewTextStringFragment::class.java.name, args) + setLeftFragment(textPreviewFragment, false) + } + + fun startContactListFragment(file: OCFile?) { + val user = user.orElseThrow(Supplier { RuntimeException() }) + ContactsPreferenceActivity.startActivityWithContactsFile(this, user, file) + } + + fun startPdfPreview(file: OCFile) { + if (fileOperationsHelper.canOpenFile(file)) { + // prefer third party PDF apps + fileOperationsHelper.openFile(file) + } else { + val pdfFragment: Fragment = newInstance(file) + + setLeftFragment(pdfFragment, false) + configureToolbarForPreview(file) + setMainFabVisible(false) + } + } + + /** + * Requests the download of the received [OCFile] , updates the UI to monitor the download progress and + * prepares the activity to preview or open the file when the download finishes. + * + * @param file [OCFile] to download and preview. + * @param parentFolder [OCFile] containing above file + */ + fun startDownloadForPreview(file: OCFile, parentFolder: OCFile?) { + if (!file.isFileEligibleForImmediatePreview) { + val currentUser = user + if (currentUser.isPresent) { + val detailFragment: Fragment = FileDetailFragment.newInstance(file, parentFolder, currentUser.get()) + setLeftFragment(detailFragment, false) + } + } + + configureToolbarForPreview(file) + mWaitingToPreview = file + requestForDownload() + setFile(file) + } + + /** + * Opens EditImageActivity with given file loaded. If file is not available locally, it will be synced before + * opening the image editor. + * + * @param file [OCFile] (image) to be loaded into image editor + */ + fun startImageEditor(file: OCFile) { + if (file.isDown) { + val editImageIntent = Intent(this, EditImageActivity::class.java) + editImageIntent.putExtra(EditImageActivity.EXTRA_FILE, file) + startActivity(editImageIntent) + } else { + mWaitingToPreview = file + requestForDownload( + file, + EditImageActivity.OPEN_IMAGE_EDITOR, + packageName, + this.javaClass.simpleName + ) + } + } + + /** + * Request stopping the upload/download operation in progress over the given [OCFile] file. + * + * @param file [OCFile] file which operation are wanted to be cancel + */ + fun cancelTransference(file: OCFile) { + fileOperationsHelper.cancelTransference(file) + if (mWaitingToPreview != null && mWaitingToPreview?.remotePath == file.remotePath) { + mWaitingToPreview = null + } + if (mWaitingToSend != null && mWaitingToSend?.remotePath == file.remotePath) { + mWaitingToSend = null + } + onTransferStateChanged(file, false, false) + } + + /** + * Request stopping all upload/download operations in progress over the given [OCFile] files. + * + * @param files collection of [OCFile] files which operations are wanted to be cancel + */ + fun cancelTransference(files: MutableCollection) { + for (file in files) { + cancelTransference(file) + } + } + + override fun onRefresh(ignoreETag: Boolean) { + syncAndUpdateFolder(ignoreETag, ignoreFocus = false) + } + + override fun onRefresh() { + syncAndUpdateFolder(ignoreETag = true, ignoreFocus = false) + } + + private fun syncAndUpdateFolder(ignoreETag: Boolean, ignoreFocus: Boolean) { + val listOfFiles = this.listOfFilesFragment + if (listOfFiles == null || listOfFiles.isSearchFragment) { + return + } + + val folder = listOfFiles.currentFile ?: return + startSyncFolderOperation(folder, ignoreETag, ignoreFocus) + } + + override fun showFiles(onDeviceOnly: Boolean, personalFiles: Boolean) { + super.showFiles(onDeviceOnly, personalFiles) + refreshOrInitOCFileListFragment() + } + + private fun refreshOrInitOCFileListFragment() { + val ocFileListFragment = this.listOfFilesFragment + if (ocFileListFragment != null && + (ocFileListFragment !is GalleryFragment) && + (ocFileListFragment !is SharedListFragment) + ) { + ocFileListFragment.refreshDirectory() + } else { + this.leftFragment = OCFileListFragment() + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + fun onMessageEvent(event: SearchEvent) { + if (SearchRemoteOperation.SearchType.PHOTO_SEARCH == event.searchType) { + Log_OC.d(this, "Switch to photo search fragment") + this.leftFragment = GalleryFragment() + } else if (event.searchType == SearchRemoteOperation.SearchType.SHARED_FILTER) { + Log_OC.d(this, "Switch to Shared fragment") + this.leftFragment = SharedListFragment() + } + + listOfFilesFragment?.setCurrentSearchType(event) + updateActionBarTitleAndHomeButton(null) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(event: SyncEventFinished) { + val bundle = event.intent.extras + val file = bundle?.get(EXTRA_FILE) as OCFile? ?: return + + if (event.intent.getBooleanExtra(TEXT_PREVIEW, false)) { + startTextPreview(file, true) + } else if (bundle.containsKey(PreviewMediaFragment.EXTRA_START_POSITION)) { + val startPosition = bundle.get(PreviewMediaFragment.EXTRA_START_POSITION) as Long + val autoPlay = bundle.get(PreviewMediaFragment.EXTRA_AUTOPLAY) as Boolean + startMediaPreview( + file, + startPosition, + autoPlay, + true, + true, + true + ) + } else if (bundle.containsKey(PreviewImageActivity.EXTRA_VIRTUAL_TYPE)) { + val virtualType = bundle.get(PreviewImageActivity.EXTRA_VIRTUAL_TYPE) as VirtualFolderType? + startImagePreview( + file, + true, + virtualType + ) + } else { + startImagePreview(file, true) + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + fun onMessageEvent(event: TokenPushEvent?) { + if (!preferences.isKeysReInitEnabled()) { + PushUtils.reinitKeys(userAccountManager) + } else { + PushUtils.pushRegistrationToServer(userAccountManager, preferences.getPushToken()) + } + } + + public override fun onStart() { + super.onStart() + + registerReceivers() + + if (SettingsActivity.isBackPressed) { + Log_OC.d(TAG, "User returned from settings activity, skipping reset content logic") + return + } + + initFile() + } + + @Suppress("LongMethod") + private fun initFile() { + val userOpt = user + if (userOpt.isEmpty) { + Log_OC.w(TAG, "user is not available, cannot init file") + return + } + val existingUser = userOpt.get() + + val storageManager = storageManager + if (storageManager == null) { + Log_OC.w(TAG, "storage manager is null, cannot init file") + return + } + + var file = getFile() + if (file != null) { + if (file.isDown && file.lastSyncDateForProperties == 0L) { + val remote = file.remotePath + val name = file.fileName + + val idx = remote.lastIndexOf(name) + if (idx > 0) { + val parentPath = remote.take(idx) + if (storageManager.getFileByPath(parentPath) == null) { + file = null + } + } else { + file = null + } + } else { + file = storageManager.getFileByPath(file.remotePath) + } + } + + // fall back to root folder + if (file == null) { + file = storageManager.getFileByPath(OCFile.ROOT_PATH) + } + + if (file == null) { + Log_OC.e(TAG, "Could not retrieve root folder – cannot continue") + return + } + + setFile(file) + + val existingAccountName = existingUser.accountName + mSwitchAccountButton.tag = existingAccountName + + DisplayUtils.setAvatar( + existingUser, + this, + getResources().getDimension(R.dimen.nav_drawer_menu_avatar_radius), + getResources(), + mSwitchAccountButton, + this + ) + val userChanged = (existingAccountName != lastDisplayedAccountName) + if (userChanged) { + composeProcessTextAlias.configure() + Log_OC.d(TAG, "Initializing Fragments in onAccountChanged..") + initFragments() + if (file.isFolder && TextUtils.isEmpty(searchQuery)) { + startSyncFolderOperation(file, false) + } + } else { + updateActionBarTitleAndHomeButton(if (file.isFolder) null else file) + } + + setNewLastDisplayedAccountName(existingAccountName) + EventBus.getDefault().post(TokenPushEvent()) + checkForNewDevVersionNecessary(applicationContext) + } + + private fun setNewLastDisplayedAccountName(accountName: String) { + preferences.lastDisplayedAccountName = accountName + lastDisplayedAccountName = accountName + } + + override fun onRestart() { + super.onRestart() + checkForNewDevVersionNecessary(applicationContext) + } + + fun setSearchQuery(query: String?) { + searchQuery = query + } + + private fun handleOpenFileViaIntent(intent: Intent) { + DisplayUtils.showSnackMessage(this, getString(R.string.retrieving_file)) + + val userName = intent.getStringExtra(KEY_ACCOUNT) + val fileId = intent.getStringExtra(KEY_FILE_ID) + val filePath = intent.getStringExtra(KEY_FILE_PATH) + + val intentData = intent.data + if (userName == null && fileId == null && intentData != null) { + openDeepLink(intentData) + } else { + val optionalUser = if (userName == null) user else userAccountManager.getUser(userName) + if (optionalUser.isPresent) { + if (!TextUtils.isEmpty(fileId)) { + openFile(optionalUser.get(), fileId) + } else if (!TextUtils.isEmpty(filePath)) { + openFileByPath(optionalUser.get(), filePath) + } else { + accountClicked(optionalUser.get()) + } + } else { + DisplayUtils.showSnackMessage(this, getString(R.string.associated_account_not_found)) + } + } + } + + private fun openDeepLink(uri: Uri) { + val linkHandler = DeepLinkHandler(userAccountManager) + val match = linkHandler.parseDeepLink(uri) + + if (match == null) { + handleDeepLink(uri) + } else if (match.users.isEmpty()) { + DisplayUtils.showSnackMessage(this, getString(R.string.associated_account_not_found)) + } else if (match.users.size == SINGLE_USER_SIZE) { + openFile(match.users[0], match.fileId) + } else { + selectUserAndOpenFile(match.users.toMutableList(), match.fileId) + } + } + + private fun selectUserAndOpenFile(users: MutableList, fileId: String?) { + val userNames = arrayOfNulls(users.size) + for (i in userNames.indices) { + userNames[i] = users[i]?.accountName + } + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(R.string.common_choose_account) + .setItems(userNames) { dialog: DialogInterface?, which: Int -> + val user = users[which] + openFile(user, fileId) + showLoadingDialog(getString(R.string.retrieving_file)) + } + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(applicationContext, builder) + + val dialog = builder.create() + dismissLoadingDialog() + dialog.show() + } + + private fun openFile(user: User?, fileId: String?) { + setUser(user) + + if (fileId == null) { + onFileRequestError(null) + return + } + + var storageManager = getStorageManager() + + if (storageManager == null) { + storageManager = FileDataStorageManager(user, contentResolver) + } + + val fetchRemoteFileTask = FetchRemoteFileTask(user, fileId, storageManager, this) + fetchRemoteFileTask.execute() + } + + private fun openFileByPath(user: User, filepath: String?) { + setUser(user) + + if (filepath == null) { + onFileRequestError(null) + return + } + + var storageManager = getStorageManager() + + if (storageManager == null) { + storageManager = FileDataStorageManager(user, contentResolver) + } + + val client: OwnCloudClient + try { + client = clientFactory.create(user) + } catch (_: CreationException) { + onFileRequestError(null) + return + } + + val getRemoteFileTask = GetRemoteFileTask(this, filepath, client, storageManager, user) + asyncRunner.postQuickTask( + getRemoteFileTask, + { result: GetRemoteFileTask.Result -> this.onFileRequestResult(result) }, + { throwable: Throwable? -> this.onFileRequestError(throwable) } + ) + } + + private fun onFileRequestError(throwable: Throwable?) { + dismissLoadingDialog() + DisplayUtils.showSnackMessage(this, getString(R.string.error_retrieving_file)) + Log_OC.e(TAG, "Requesting file from remote failed!", throwable) + } + + private fun onFileRequestResult(result: GetRemoteFileTask.Result) { + dismissLoadingDialog() + + file = result.file + + val fileFragment = OCFileListFragment() + this.leftFragment = fileFragment + + supportFragmentManager.executePendingTransactions() + + fileFragment.onItemClicked(result.file) + } + + fun performUnifiedSearch(query: String, listOfHiddenFiles: ArrayList?) { + val unifiedSearchFragment = + UnifiedSearchFragment.newInstance( + query, + listOfHiddenFiles, + currentDir?.decryptedRemotePath + ) + setLeftFragment(unifiedSearchFragment, false) + } + + fun setMainFabVisible(visible: Boolean) { + val visibility = if (visible) View.VISIBLE else View.GONE + binding.fabMain.visibility = visibility + } + + fun showFile(selectedFile: OCFile?, message: String?) { + getOCFileListFragmentFromFile(object : TransactionInterface { + override fun onOCFileListFragmentComplete(fragment: OCFileListFragment) { + dismissLoadingDialog() + + if (message.isNullOrEmpty()) { + val current = getCurrentDir() + fragment.listDirectory(current, file, MainApp.isOnlyOnDevice()) + file = current + updateActionBarTitleAndHomeButton(null) + } else { + fragment.view?.let { DisplayUtils.showSnackMessage(it, message) } + } + + selectedFile?.let(fragment::onItemClicked) + } + }) + } + + override fun onFilesRemoved() { + refreshCurrentDirectory() + } + + private fun handleEcosystemIntent(intent: Intent?) { + ecosystemManager.receiveAccount( + intent, + object : AccountReceiverCallback { + override fun onAccountReceived(accountName: String) { + val account = accountManager.getUser(accountName).orElse(null) + ?: run { + Log_OC.w(TAG, "user is not present") + DisplayUtils.showSnackMessage(this@FileDisplayActivity, R.string.account_not_found) + return + } + + accountClicked(account) + } + + override fun onAccountError(reason: String) { + Log_OC.w(TAG, "handleEcosystemIntent: $reason") + } + } + ) + } + + // region MetadataSyncJob + private fun startMetadataSyncForRoot() { + backgroundJobManager.startMetadataSyncJob(OCFile.ROOT_PATH) + } + + private fun startMetadataSyncForCurrentDir() { + val currentDirId = file?.decryptedRemotePath ?: return + backgroundJobManager.startMetadataSyncJob(currentDirId) + } + // endregion + + companion object { + const val RESTART: String = "RESTART" + const val ALL_FILES: String = "ALL_FILES" + const val LIST_GROUPFOLDERS: String = "LIST_GROUPFOLDERS" + const val SINGLE_USER_SIZE: Int = 1 + const val OPEN_FILE: String = "NC_OPEN_FILE" + const val ON_DEVICE = "ON_DEVICE" + + const val TAG_PUBLIC_LINK: String = "PUBLIC_LINK" + const val FTAG_CHOOSER_DIALOG: String = "CHOOSER_DIALOG" + const val KEY_FILE_ID: String = "KEY_FILE_ID" + const val KEY_FILE_PATH: String = "KEY_FILE_PATH" + const val KEY_ACCOUNT: String = "KEY_ACCOUNT" + const val KEY_IS_SORT_GROUP_VISIBLE: String = "KEY_IS_SORT_GROUP_VISIBLE" + + private const val KEY_WAITING_TO_PREVIEW = "WAITING_TO_PREVIEW" + private const val KEY_SYNC_IN_PROGRESS = "SYNC_IN_PROGRESS" + private const val KEY_WAITING_TO_SEND = "WAITING_TO_SEND" + private const val DIALOG_TAG_SHOW_TOS = "DIALOG_TAG_SHOW_TOS" + + private const val ON_RESUMED_RESET_DELAY = 10000L + private const val SEARCH_VIEW_FOCUS_DELAY = 100L + + const val ACTION_DETAILS: String = "com.owncloud.android.ui.activity.action.DETAILS" + + @JvmField + val REQUEST_CODE__SELECT_CONTENT_FROM_APPS: Int = REQUEST_CODE__LAST_SHARED + 1 + + @JvmField + val REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM: Int = REQUEST_CODE__LAST_SHARED + 2 + + @JvmField + val REQUEST_CODE__MOVE_OR_COPY_FILES: Int = REQUEST_CODE__LAST_SHARED + 3 + + @JvmField + val REQUEST_CODE__UPLOAD_FROM_CAMERA: Int = REQUEST_CODE__LAST_SHARED + 5 + + @JvmField + val REQUEST_CODE__UPLOAD_FROM_VIDEO_CAMERA: Int = REQUEST_CODE__LAST_SHARED + 6 + + @JvmField + val REQUEST_CODE__SELECT_CONTENT_FROM_APPS_AUTO_RENAME: Int = REQUEST_CODE__LAST_SHARED + 7 + + protected val DELAY_TO_REQUEST_REFRESH_OPERATION_LATER: Long = DELAY_TO_REQUEST_OPERATIONS_LATER + 350 + + private val TAG: String = FileDisplayActivity::class.java.getSimpleName() + + const val TAG_LIST_OF_FILES: String = "LIST_OF_FILES" + + const val TEXT_PREVIEW: String = "TEXT_PREVIEW" + + const val KEY_IS_SEARCH_OPEN: String = "IS_SEARCH_OPEN" + const val KEY_SEARCH_QUERY: String = "SEARCH_QUERY" + + @JvmStatic + fun openFileIntent(context: Context?, user: User?, file: OCFile?): Intent { + val intent = Intent(context, PreviewImageActivity::class.java) + intent.putExtra(EXTRA_FILE, file) + intent.putExtra(EXTRA_USER, user) + return intent + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FilePickerActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FilePickerActivity.kt new file mode 100644 index 000000000000..050331b99a8d --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/FilePickerActivity.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.os.Bundle +import com.owncloud.android.R +import com.owncloud.android.ui.fragment.OCFileListFragment + +/** + * File picker of remote files + */ +class FilePickerActivity : FolderPickerActivity() { + + override fun createFragments() { + val listOfFiles = OCFileListFragment() + val args = Bundle() + args.putBoolean(OCFileListFragment.ARG_ONLY_FOLDERS_CLICKABLE, true) + args.putBoolean(OCFileListFragment.ARG_HIDE_FAB, true) + args.putBoolean(OCFileListFragment.ARG_HIDE_ITEM_OPTIONS, true) + args.putBoolean(OCFileListFragment.ARG_SEARCH_ONLY_FOLDER, false) + args.putBoolean(OCFileListFragment.ARG_FILE_SELECTABLE, true) + args.putString(OCFileListFragment.ARG_MIMETYPE, intent.getStringExtra(OCFileListFragment.ARG_MIMETYPE)) + listOfFiles.arguments = args + val transaction = supportFragmentManager.beginTransaction() + transaction.add(R.id.fragment_container, listOfFiles, TAG_LIST_OF_FOLDERS) + transaction.commit() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt new file mode 100644 index 000000000000..f4fd4309cc08 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt @@ -0,0 +1,698 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.accounts.AuthenticatorException +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Resources +import android.os.Bundle +import android.os.Parcelable +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.nextcloud.client.account.User +import com.nextcloud.client.di.Injectable +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.fileNameValidator.FileNameValidator +import com.owncloud.android.R +import com.owncloud.android.databinding.FilesFolderPickerBinding +import com.owncloud.android.databinding.FilesPickerBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.SearchRemoteOperation +import com.owncloud.android.operations.CreateFolderOperation +import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.services.OperationsService +import com.owncloud.android.syncadapter.FileSyncAdapter +import com.owncloud.android.ui.dialog.CreateFolderDialogFragment +import com.owncloud.android.ui.dialog.SortingOrderDialogFragment.OnSortingOrderListener +import com.owncloud.android.ui.events.SearchEvent +import com.owncloud.android.ui.fragment.EmptyListState +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.fragment.OCFileListFragment +import com.owncloud.android.utils.DataHolderUtil +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.ErrorMessageAdapter +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.PathUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +@Suppress("Detekt.TooManyFunctions") +open class FolderPickerActivity : + FileActivity(), + FileFragment.ContainerActivity, + OnEnforceableRefreshListener, + Injectable, + OnSortingOrderListener { + + private var mSyncBroadcastReceiver: SyncBroadcastReceiver? = null + private var mSearchOnlyFolders = false + var isDoNotEnterEncryptedFolder = false + private set + + private var captionText: String? = null + + private var action: String? = null + private var targetFilePaths: ArrayList? = null + + private lateinit var filesPickerBinding: FilesPickerBinding + private lateinit var folderPickerBinding: FilesFolderPickerBinding + + @Inject + lateinit var localBroadcastManager: LocalBroadcastManager + + private fun initBinding() { + if (this is FilePickerActivity) { + filesPickerBinding = FilesPickerBinding.inflate(layoutInflater) + setContentView(filesPickerBinding.root) + } else { + folderPickerBinding = FilesFolderPickerBinding.inflate(layoutInflater) + setContentView(folderPickerBinding.root) + } + + OCFileListFragment.isMultipleFileSelectedForCopyOrMove = true + } + + override fun onCreate(savedInstanceState: Bundle?) { + Log_OC.d(TAG, "onCreate() start") + + super.onCreate(savedInstanceState) + + initBinding() + initControls() + setupToolbar() + setupActionBar() + setupAction() + initTargetFilesPath() + + if (savedInstanceState == null) { + createFragments() + } + + updateActionBarTitleAndHomeButtonByString(captionText) + handleBackPress() + } + + override fun onDestroy() { + OCFileListFragment.isMultipleFileSelectedForCopyOrMove = false + super.onDestroy() + } + + private fun setupActionBar() { + findViewById(R.id.sort_list_button_group).visibility = + View.VISIBLE + findViewById(R.id.switch_grid_view_button).visibility = + View.GONE + } + + private fun setupAction() { + action = intent.getStringExtra(EXTRA_ACTION) + + if (action != null && action == CHOOSE_LOCATION) { + setupUIForChooseButton() + } else { + captionText = themeUtils.getDefaultDisplayNameForRootFolder(this) + } + } + + private fun initTargetFilesPath() { + targetFilePaths = intent.getStringArrayListExtra(EXTRA_FILE_PATHS) + } + + private fun setupUIForChooseButton() { + captionText = resources.getText(R.string.folder_picker_choose_caption_text).toString() + mSearchOnlyFolders = true + isDoNotEnterEncryptedFolder = true + + if (this is FilePickerActivity) { + return + } else { + folderPickerBinding.folderPickerBtnCopy.visibility = View.GONE + folderPickerBinding.folderPickerBtnMove.visibility = View.GONE + folderPickerBinding.folderPickerBtnChoose.visibility = View.VISIBLE + folderPickerBinding.chooseButtonSpacer.visibility = View.VISIBLE + folderPickerBinding.moveOrCopyButtonSpacer.visibility = View.GONE + } + } + + private fun handleBackPress() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + listOfFilesFragment?.let { + val levelsUp = it.onBrowseUp() + + if (levelsUp == 0) { + finish() + return + } + + file = it.currentFile + updateUiElements() + } + } + } + ) + } + + override fun onActionModeStarted(mode: ActionMode) { + super.onActionModeStarted(mode) + + if (action == null) { + return + } + + updateFileFromDB() + var folder = file + if (folder == null || !folder.isFolder) { + file = storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) + folder = file + } + + listOfFilesFragment?.listDirectory(folder, false) + startSyncFolderOperation(folder, false) + updateUiElements() + } + + private val activity: Activity + get() = this + + protected open fun createFragments() { + val listOfFiles = OCFileListFragment() + + val bundle = Bundle().apply { + putBoolean(OCFileListFragment.ARG_ONLY_FOLDERS_CLICKABLE, true) + putBoolean(OCFileListFragment.ARG_HIDE_FAB, true) + putBoolean(OCFileListFragment.ARG_HIDE_ITEM_OPTIONS, true) + putBoolean(OCFileListFragment.ARG_SEARCH_ONLY_FOLDER, mSearchOnlyFolders) + } + + listOfFiles.arguments = bundle + + val transaction = supportFragmentManager.beginTransaction() + transaction.add(R.id.fragment_container, listOfFiles, TAG_LIST_OF_FOLDERS) + transaction.commit() + } + + protected val listOfFilesFragment: OCFileListFragment? + get() { + val listOfFiles = supportFragmentManager.findFragmentByTag(TAG_LIST_OF_FOLDERS) + + return if (listOfFiles != null) { + return listOfFiles as OCFileListFragment? + } else { + Log_OC.e(TAG, "Access to non existing list of files fragment!!") + null + } + } + + /** + * {@inheritDoc} + * + * + * Updates action bar and second fragment, if in dual pane mode. + */ + override fun onBrowsedDownTo(directory: OCFile) { + file = directory + updateUiElements() + startSyncFolderOperation(directory, false) + } + + override fun onSavedCertificate() { + startSyncFolderOperation(currentDir, false) + } + + private fun startSyncFolderOperation(folder: OCFile?, ignoreETag: Boolean) { + val optionalUser = user ?: return + if (optionalUser.isEmpty) { + return + } + val user: User = optionalUser.get() + listOfFilesFragment?.setEmptyListMessage(EmptyListState.LOADING) + + lifecycleScope.launch(Dispatchers.IO) { + val currentSyncTime = System.currentTimeMillis() + val operation = RefreshFolderOperation( + folder, + currentSyncTime, + false, + ignoreETag, + storageManager, + user, + applicationContext + ) + operation.execute( + account, + this@FolderPickerActivity, + { _, _ -> + listOfFilesFragment?.setEmptyListMessage(EmptyListState.LOCAL_FILE_LIST_EMPTY_FILE) + }, + null + ) + } + } + + override fun onResume() { + super.onResume() + Log_OC.e(TAG, "onResume() start") + + val extraFolder = intent.getParcelableArgument(EXTRA_FOLDER, OCFile::class.java) + if (extraFolder != null) { + file = extraFolder + } else { + file = listOfFilesFragment?.currentFile + } + refreshListOfFilesFragment(file) + updateUiElements() + + val intentFilter = getSyncIntentFilter() + mSyncBroadcastReceiver = SyncBroadcastReceiver() + mSyncBroadcastReceiver?.let { + localBroadcastManager.registerReceiver(it, intentFilter) + } + + Log_OC.d(TAG, "onResume() end") + } + + private fun getSyncIntentFilter(): IntentFilter = IntentFilter(FileSyncAdapter.EVENT_FULL_SYNC_START).apply { + addAction(FileSyncAdapter.EVENT_FULL_SYNC_END) + addAction(FileSyncAdapter.EVENT_FULL_SYNC_FOLDER_CONTENTS_SYNCED) + addAction(RefreshFolderOperation.EVENT_SINGLE_FOLDER_CONTENTS_SYNCED) + addAction(RefreshFolderOperation.EVENT_SINGLE_FOLDER_SHARES_SYNCED) + } + + override fun onPause() { + Log_OC.e(TAG, "onPause() start") + + if (mSyncBroadcastReceiver != null) { + localBroadcastManager.unregisterReceiver(mSyncBroadcastReceiver!!) + mSyncBroadcastReceiver = null + } + + Log_OC.d(TAG, "onPause() end") + super.onPause() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_folder_picker, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + var retval = true + val itemId = item.itemId + + if (itemId == R.id.action_create_dir) { + val dialog = CreateFolderDialogFragment.newInstance(currentFolder, false) + dialog.show(supportFragmentManager, CreateFolderDialogFragment.CREATE_FOLDER_FRAGMENT) + } else if (itemId == android.R.id.home) { + val currentDir = currentFolder + if (currentDir != null && currentDir.parentId != 0L) { + onBackPressedDispatcher.onBackPressed() + } + } else { + retval = super.onOptionsItemSelected(item) + } + + return retval + } + + // If the file is null, take the root folder to avoid any error in functions depending on this one + val currentFolder: OCFile? + get() { + val currentFile = file + val storageManager = storageManager + + return if (currentFile != null) { + if (currentFile.isFolder) { + currentFile + } else if (currentFile.remotePath != null) { + val parentPath = File(currentFile.remotePath).parent + storageManager.getFileByEncryptedRemotePath(parentPath) + } else { + null + } + } else { + storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) + } + } + + private fun refreshListOfFilesFragment(directory: OCFile?) { + listOfFilesFragment?.listDirectory(directory, false) + } + + fun browseToRoot() { + listOfFilesFragment?.let { + val root = storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) + it.listDirectory(root, false) + file = it.currentFile + updateUiElements() + startSyncFolderOperation(root, false) + } + } + + private fun updateUiElements() { + toggleChooseEnabled() + updateNavigationElementsInActionBar() + } + + @Suppress("ReturnCount") + private fun toggleChooseEnabled() { + if (this is FilePickerActivity) { + return + } + + val selectedFolderPathTitle = getSelectedFolderPathTitle() + val optionalCapabilities = capabilities + if (optionalCapabilities.isEmpty) { + return + } + + val isFolderPathValid = if (selectedFolderPathTitle != null) { + FileNameValidator.checkFolderPath( + selectedFolderPathTitle, + optionalCapabilities.get(), + this + ) + } else { + true + } + + checkButtonStates(isFolderPathValid) + + if (!isFolderPathValid) { + DisplayUtils.showSnackMessage( + this, + R.string.file_name_validator_error_contains_reserved_names_or_invalid_characters + ) + return + } + } + + private fun checkButtonStates(isConditionMet: Boolean) { + folderPickerBinding.run { + folderPickerBtnChoose.isEnabled = isConditionMet + folderPickerBtnCopy.isEnabled = isFolderSelectable(COPY) && isConditionMet + folderPickerBtnMove.isEnabled = isFolderSelectable(MOVE) && isConditionMet + } + } + + // for copy and move, disable selecting parent folder of target files + private fun isFolderSelectable(type: String): Boolean = when { + action != MOVE_OR_COPY -> true + + action == MOVE_OR_COPY && type == COPY -> true + + targetFilePaths.isNullOrEmpty() -> true + + file?.isFolder != true -> true + + // all of the target files are already in the selected directory + targetFilePaths?.all { PathUtils.isDirectParent(file?.remotePath ?: "", it) } == true -> false + + // some of the target files are parents of the selected folder + targetFilePaths?.any { PathUtils.isAncestor(it, file?.remotePath ?: "") } == true -> false + + else -> true + } + + private fun updateNavigationElementsInActionBar() { + val currentDir = currentFolder + supportActionBar?.let { actionBar -> + val atRoot = (currentDir == null || currentDir.parentId == 0L) + actionBar.setDisplayHomeAsUpEnabled(!atRoot) + actionBar.setHomeButtonEnabled(!atRoot) + getSelectedFolderPathTitle()?.let { + viewThemeUtils.files.themeActionBar(this, actionBar, it) + } + } + } + + private fun getSelectedFolderPathTitle(): String? { + val atRoot = (currentDir == null || currentDir?.parentId == 0L) + return if (atRoot) captionText ?: "" else currentDir?.fileName + } + + private fun initControls() { + if (this is FilePickerActivity) { + viewThemeUtils.material.colorMaterialButtonPrimaryFilled(filesPickerBinding.folderPickerBtnCancel) + filesPickerBinding.folderPickerBtnCancel.setOnClickListener { finish() } + } else { + viewThemeUtils.material.colorMaterialButtonText(folderPickerBinding.folderPickerBtnCancel) + folderPickerBinding.folderPickerBtnCancel.setOnClickListener { finish() } + + viewThemeUtils.material.colorMaterialButtonPrimaryTonal(folderPickerBinding.folderPickerBtnChoose) + folderPickerBinding.folderPickerBtnChoose.setOnClickListener { processOperation(null) } + + viewThemeUtils.material.colorMaterialButtonPrimaryFilled(folderPickerBinding.folderPickerBtnCopy) + folderPickerBinding.folderPickerBtnCopy.setOnClickListener { + processOperation( + OperationsService.ACTION_COPY_FILE + ) + } + + viewThemeUtils.material.colorMaterialButtonPrimaryTonal(folderPickerBinding.folderPickerBtnMove) + folderPickerBinding.folderPickerBtnMove.setOnClickListener { + processOperation( + OperationsService.ACTION_MOVE_FILE + ) + } + } + } + + @Suppress("MagicNumber") + private fun processOperation(action: String?) { + val i = intent + val resultData = Intent() + resultData.putExtra(EXTRA_FOLDER, listOfFilesFragment?.currentFile) + + i.getParcelableArrayListExtra(EXTRA_FILES)?.let { targetFiles -> + resultData.putParcelableArrayListExtra(EXTRA_FILES, targetFiles) + } + + targetFilePaths?.let { filePaths -> + action?.let { action -> + fileOperationsHelper.moveOrCopyFiles(action, filePaths, file) + } + + resultData.putStringArrayListExtra(EXTRA_FILE_PATHS, filePaths) + } + + setResult(RESULT_OK, resultData) + finish() + } + + override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>) { + super.onRemoteOperationFinish(operation, result) + if (operation is CreateFolderOperation) { + onCreateFolderOperationFinish(operation, result) + } + } + + /** + * Updates the view associated to the activity after the finish of an operation trying to create a new folder. + * + * @param operation Creation operation performed. + * @param result Result of the creation. + */ + private fun onCreateFolderOperationFinish(operation: CreateFolderOperation, result: RemoteOperationResult<*>) { + if (result.isSuccess) { + val fileListFragment = listOfFilesFragment + fileListFragment?.onItemClicked(storageManager.getFileByPath(operation.remotePath)) + } else { + try { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, resources) + ) + } catch (e: Resources.NotFoundException) { + Log_OC.e(TAG, "Error while trying to show fail message ", e) + } + } + } + + fun search(query: String?) { + if (query == null) { + return + } + + listOfFilesFragment?.onMessageEvent( + SearchEvent( + query, + SearchRemoteOperation.SearchType.FILE_SEARCH + ) + ) + } + + private inner class SyncBroadcastReceiver : BroadcastReceiver() { + /** + * [BroadcastReceiver] to enable syncing feedback in UI + */ + @Suppress( + "Detekt.ComplexMethod", + "Detekt.NestedBlockDepth", + "Detekt.TooGenericExceptionCaught", + "Detekt.LongMethod" + ) // legacy code + override fun onReceive(context: Context, intent: Intent) { + try { + val event = intent.action + Log_OC.d(TAG, "Received broadcast $event") + val accountName = intent.getStringExtra(FileSyncAdapter.EXTRA_ACCOUNT_NAME) + val syncFolderRemotePath = intent.getStringExtra(FileSyncAdapter.EXTRA_FOLDER_PATH) + + val syncResult = DataHolderUtil.getInstance() + .retrieve(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT)) as RemoteOperationResult<*> + val sameAccount = (account != null && accountName == account.name && storageManager != null) + + if (!sameAccount) { + return + } + + if (FileSyncAdapter.EVENT_FULL_SYNC_START != event) { + var (currentFile, currentDir) = getCurrentFileAndDirectory() + + if (currentDir == null) { + browseRootForRemovedFolder() + } else { + if (currentFile == null && file?.isFolder == false) { + // currently selected file was removed in the server, and now we know it + currentFile = currentDir + } + if (currentDir.remotePath == syncFolderRemotePath) { + listOfFilesFragment?.listDirectory(currentDir, false) + } + file = currentFile + } + + checkCredentials(syncResult, event) + } + + DataHolderUtil.getInstance().delete(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT)) + } catch (e: RuntimeException) { + Log_OC.e(TAG, "Error on broadcast receiver", e) + // avoid app crashes after changing the serial id of RemoteOperationResult + // in owncloud library with broadcast notifications pending to process + DataHolderUtil.getInstance().delete(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT)) + } finally { + listOfFilesFragment?.setEmptyListMessage(EmptyListState.LOCAL_FILE_LIST_EMPTY_FILE) + } + } + + private fun getCurrentFileAndDirectory(): Pair { + val currentFile = file?.let { storageManager.getFileByEncryptedRemotePath(it.remotePath) } + + val currentDir = if (currentFolder == null) { + null + } else { + storageManager.getFileByEncryptedRemotePath( + currentFolder?.remotePath + ) + } + + return Pair(currentFile, currentDir) + } + + private fun browseRootForRemovedFolder() { + DisplayUtils.showSnackMessage( + activity, + R.string.sync_current_folder_was_removed, + currentFolder?.fileName + ) + browseToRoot() + } + + private fun checkCredentials(syncResult: RemoteOperationResult<*>, event: String?) { + if (RefreshFolderOperation.EVENT_SINGLE_FOLDER_CONTENTS_SYNCED == event && !syncResult.isSuccess) { + if (ResultCode.UNAUTHORIZED == syncResult.code || + ( + syncResult.isException && + syncResult.exception is AuthenticatorException + ) + ) { + requestCredentialsUpdate() + } else if (ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED == syncResult.code) { + showUntrustedCertDialog(syncResult) + } + } + } + } + + override fun showDetails(file: OCFile) { + // not used at the moment + } + + override fun showDetails(file: OCFile, activeTab: Int) { + // not used at the moment + } + + /** + * {@inheritDoc} + */ + override fun onTransferStateChanged(file: OCFile, downloading: Boolean, uploading: Boolean) { + // not used at the moment + } + + override fun onRefresh() { + refreshList(true) + } + + override fun onRefresh(enforced: Boolean) { + refreshList(enforced) + } + + private fun refreshList(ignoreETag: Boolean) { + listOfFilesFragment?.currentFile?.let { + startSyncFolderOperation(it, ignoreETag) + } + } + + override fun onSortingOrderChosen(selection: FileSortOrder?) { + listOfFilesFragment?.sortFiles(selection) + } + + companion object { + const val EXTRA_FOLDER = "com.owncloud.android.ui.activity.FolderPickerActivity".plus(".EXTRA_FOLDER") + + @JvmField + @Deprecated( + """This leads to crashes when too many files are passed. Use EXTRA_FILE_PATHS instead, or + better yet, store the target files wherever you need to use them instead of passing them through this activity.""" + ) + val EXTRA_FILES = FolderPickerActivity::class.java.canonicalName?.plus(".EXTRA_FILES") + + @JvmField + val EXTRA_FILE_PATHS = FolderPickerActivity::class.java.canonicalName?.plus(".EXTRA_FILE_PATHS") + + @JvmField + val EXTRA_ACTION = FolderPickerActivity::class.java.canonicalName?.plus(".EXTRA_ACTION") + + const val MOVE_OR_COPY = "MOVE_OR_COPY" + const val CHOOSE_LOCATION = "CHOOSE_LOCATION" + private val TAG = FolderPickerActivity::class.java.simpleName + private const val MOVE = "MOVE" + private const val COPY = "COPY" + + const val TAG_LIST_OF_FOLDERS = "LIST_OF_FOLDERS" + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt new file mode 100644 index 000000000000..a563378545c4 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt @@ -0,0 +1,240 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.activity + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ArrayAdapter +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.download.FileDownloadWorker +import com.nextcloud.utils.extensions.hourPlural +import com.nextcloud.utils.extensions.minPlural +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.R +import com.owncloud.android.databinding.InternalTwoWaySyncLayoutBinding +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.adapter.InternalTwoWaySyncAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class InternalTwoWaySyncActivity : + DrawerActivity(), + Injectable, + InternalTwoWaySyncAdapter.InternalTwoWaySyncAdapterOnUpdate { + private val tag = "InternalTwoWaySyncActivity" + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + lateinit var binding: InternalTwoWaySyncLayoutBinding + + private lateinit var internalTwoWaySyncAdapter: InternalTwoWaySyncAdapter + private var disableForAllFoldersMenuButton: MenuItem? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + internalTwoWaySyncAdapter = + InternalTwoWaySyncAdapter(fileDataStorageManager, user.get(), this, this, viewThemeUtils) + + binding = InternalTwoWaySyncLayoutBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupToolbar() + setupActionBar() + setupTwoWaySyncAdapter() + setupEmptyList() + setupTwoWaySyncToggle() + setupTwoWaySyncInterval() + checkLayoutVisibilities(preferences.isTwoWaySyncEnabled) + } + + private fun setupActionBar() { + updateActionBarTitleAndHomeButtonByString(getString(R.string.two_way_sync_activity_title)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + @SuppressLint("NotifyDataSetChanged") + private fun setupTwoWaySyncAdapter() { + if (preferences.isTwoWaySyncEnabled) { + binding.run { + list.run { + setEmptyView(emptyList.emptyListView) + adapter = internalTwoWaySyncAdapter + layoutManager = LinearLayoutManager(this@InternalTwoWaySyncActivity) + adapter?.notifyDataSetChanged() + } + } + } + } + + private fun setupEmptyList() { + binding.emptyList.run { + emptyListViewHeadline.run { + visibility = View.VISIBLE + setText(R.string.two_way_sync_activity_empty_list_title) + } + + emptyListViewText.run { + visibility = View.VISIBLE + setText(R.string.two_way_sync_activity_empty_list_desc) + } + + emptyListIcon.run { + visibility = View.VISIBLE + setImageDrawable( + viewThemeUtils.platform.tintDrawable( + context, + R.drawable.ic_sync, + ColorRole.PRIMARY + ) + ) + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun disableTwoWaySyncAndWorkers() { + lifecycleScope.launch(Dispatchers.IO) { + try { + backgroundJobManager.cancelTwoWaySyncJob() + + val currentUser = user.get() + + val folders = fileDataStorageManager.getInternalTwoWaySyncFolders(currentUser) + folders.forEach { folder -> + FileDownloadWorker.cancelOperation(currentUser.accountName, folder.fileId) + backgroundJobManager.cancelFilesDownloadJob(currentUser.accountName, folder.fileId) + + folder.internalFolderSyncTimestamp = -1L + fileDataStorageManager.saveFile(folder) + } + + withContext(Dispatchers.Main) { + internalTwoWaySyncAdapter.update() + } + } catch (e: Exception) { + Log_OC.d(tag, "Error caught at disableTwoWaySyncAndWorkers: $e") + } + } + } + + @Suppress("MagicNumber") + private fun setupTwoWaySyncInterval() { + val durations = listOf( + 15.minutes to minPlural(15), + 30.minutes to minPlural(30), + 45.minutes to minPlural(45), + 1.hours to hourPlural(1), + 2.hours to hourPlural(2), + 4.hours to hourPlural(4), + 6.hours to hourPlural(6), + 8.hours to hourPlural(8), + 12.hours to hourPlural(12), + 24.hours to hourPlural(24) + ) + val selectedDuration = durations.find { it.first.inWholeMinutes == preferences.twoWaySyncInterval } + + val adapter = ArrayAdapter( + this, + android.R.layout.simple_dropdown_item_1line, + durations.map { it.second } + ) + + binding.twoWaySyncInterval.run { + setAdapter(adapter) + setText(selectedDuration?.second ?: minPlural(15), false) + setOnItemClickListener { _, _, position, _ -> + handleDurationSelected(durations[position].first.inWholeMinutes) + } + } + viewThemeUtils.material.colorTextInputLayout(binding.twoWaySyncIntervalLayout) + } + + private fun handleDurationSelected(duration: Long) { + preferences.twoWaySyncInterval = duration + backgroundJobManager.scheduleInternal2WaySync(duration) + } + + private fun setupTwoWaySyncToggle() { + binding.twoWaySyncToggle.isChecked = preferences.isTwoWaySyncEnabled + binding.twoWaySyncToggle.setOnCheckedChangeListener { _, isChecked -> + preferences.setTwoWaySyncStatus(isChecked) + setupTwoWaySyncAdapter() + checkLayoutVisibilities(isChecked) + checkDisableForAllFoldersMenuButtonVisibility() + + if (isChecked) { + backgroundJobManager.scheduleInternal2WaySync(preferences.twoWaySyncInterval) + } else { + backgroundJobManager.cancelTwoWaySyncJob() + } + } + viewThemeUtils.material.colorMaterialSwitch(binding.twoWaySyncToggle) + } + + private fun checkLayoutVisibilities(condition: Boolean) { + binding.listFrameLayout.setVisibleIf(condition) + binding.twoWaySyncIntervalLayout.setVisibleIf(condition) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.activity_internal_two_way_sync, menu) + disableForAllFoldersMenuButton = menu?.findItem(R.id.action_dismiss_two_way_sync) + checkDisableForAllFoldersMenuButtonVisibility() + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + } + + R.id.action_dismiss_two_way_sync -> { + disableTwoWaySyncAndWorkers() + } + } + + return super.onOptionsItemSelected(item) + } + + private fun checkDisableForAllFoldersMenuButtonVisibility() { + lifecycleScope.launch { + val folderSize = withContext(Dispatchers.IO) { + fileDataStorageManager.getInternalTwoWaySyncFolders(user.get()).size + } + + checkDisableForAllFoldersMenuButtonVisibility(preferences.isTwoWaySyncEnabled, folderSize) + } + } + + private fun checkDisableForAllFoldersMenuButtonVisibility(isTwoWaySyncEnabled: Boolean, folderSize: Int) { + val showDisableButton = isTwoWaySyncEnabled && folderSize > 0 + + disableForAllFoldersMenuButton?.let { + it.setVisible(showDisableButton) + it.setEnabled(showDisableButton) + } + } + + override fun onUpdate(folderSize: Int) { + checkDisableForAllFoldersMenuButtonVisibility(preferences.isTwoWaySyncEnabled, folderSize) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt new file mode 100644 index 000000000000..47de3ea71b76 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt @@ -0,0 +1,528 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Chawki Chouib + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016-2018 Andy Scherzinger + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.AccountManagerCallback +import android.accounts.AccountManagerFuture +import android.accounts.OperationCanceledException +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.os.Handler +import android.view.MenuItem +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.annotation.VisibleForTesting +import androidx.appcompat.widget.PopupMenu +import androidx.fragment.app.FragmentManager +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.common.collect.Sets +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.onboarding.FirstRunActivity +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.mdm.MDMConfig.multiAccountSupport +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.authentication.AuthenticatorActivity +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.UserInfo +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.services.OperationsService.OperationsServiceBinder +import com.owncloud.android.ui.adapter.UserListAdapter +import com.owncloud.android.ui.adapter.UserListItem +import com.owncloud.android.ui.dialog.AccountRemovalDialog.Companion.newInstance +import com.owncloud.android.ui.events.AccountRemovedEvent +import com.owncloud.android.ui.helpers.FileOperationsHelper +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import javax.inject.Inject + +/** + * An Activity that allows the user to manage accounts. + */ +class ManageAccountsActivity : + FileActivity(), + UserListAdapter.Listener, + AccountManagerCallback, + ComponentsGetter, + UserListAdapter.ClickListener { + + private var recyclerView: RecyclerView? = null + private val handler = Handler() + private var accountName: String? = null + private var userListAdapter: UserListAdapter? = null + private var originalUsers: Set? = null + private var originalCurrentUser: String? = null + + private var multipleAccountsSupported = false + private val fileDownloadStartedReceiver = FileDownloadStartedReceiver() + + @Inject + lateinit var localBroadcastManager: LocalBroadcastManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.accounts_layout) + + setupToolbar() + setupActionBar() + setupUsers() + + @Suppress("DEPRECATION") + arbitraryDataProvider = ArbitraryDataProviderImpl(this) + multipleAccountsSupported = multiAccountSupport(this) + + setupUserList() + handleBackPress() + } + + private fun setupUsers() { + val users = accountManager.allUsers + originalUsers = toAccountNames(users) + + user.ifPresent { + originalCurrentUser = user.get().accountName + } + } + + private fun setupActionBar() { + supportActionBar?.let { + it.setDisplayHomeAsUpEnabled(true) + it.setDisplayShowHomeEnabled(true) + viewThemeUtils.files.themeActionBar(this, it, R.string.prefs_manage_accounts) + } + } + + private fun setupUserList() { + userListAdapter = UserListAdapter( + this, + accountManager, + userListItems, + this, + multipleAccountsSupported, + true, + true, + viewThemeUtils + ) + + recyclerView = findViewById(R.id.account_list) + recyclerView?.setAdapter(userListAdapter) + recyclerView?.setLayoutManager(LinearLayoutManager(this)) + } + + @Suppress("ReturnCount") + @Deprecated("Use ActivityResultLauncher") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (resultCode != KEY_DELETE_CODE || data == null) { + return + } + + val bundle = data.extras + if (bundle == null || !bundle.containsKey(UserInfoActivity.KEY_ACCOUNT)) { + return + } + + val account = bundle.getParcelableArgument(UserInfoActivity.KEY_ACCOUNT, Account::class.java) ?: return + val user = accountManager.getUser(account.name).orElseThrow { RuntimeException() } + accountName = account.name + performAccountRemoval(user) + } + + private fun handleBackPress() { + onBackPressedDispatcher.addCallback( + this, + onBackPressedCallback + ) + } + + private val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val resultIntent = Intent() + + if (accountManager.allUsers.isNotEmpty()) { + resultIntent.putExtra(KEY_ACCOUNT_LIST_CHANGED, hasAccountListChanged()) + resultIntent.putExtra(KEY_CURRENT_ACCOUNT_CHANGED, hasCurrentAccountChanged()) + setResult(RESULT_OK, resultIntent) + } else { + val intent = Intent(this@ManageAccountsActivity, AuthenticatorActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + startActivity(intent) + } + + finish() + } + } + + /** + * checks the set of actual accounts against the set of original accounts when the activity has been started. + * + * @return true if account list has changed, false if not + */ + private fun hasAccountListChanged(): Boolean { + val users = accountManager.allUsers + val newList: MutableList = ArrayList() + for (user in users) { + val pendingForRemoval = arbitraryDataProvider.getBooleanValue(user, PENDING_FOR_REMOVAL) + + if (!pendingForRemoval) { + newList.add(user) + } + } + val actualAccounts = toAccountNames(newList) + return originalUsers != actualAccounts + } + + /** + * checks actual current account against current accounts when the activity has been started. + * + * @return true if account list has changed, false if not + */ + private fun hasCurrentAccountChanged(): Boolean { + val user = userAccountManager.user + return if (user.isAnonymous) { + true + } else { + user.accountName != originalCurrentUser + } + } + + private val userListItems: List + get() { + val users = accountManager.allUsers + val userListItems: MutableList = + ArrayList(users.size) + for (user in users) { + val pendingForRemoval = + arbitraryDataProvider.getBooleanValue(user, PENDING_FOR_REMOVAL) + userListItems.add(UserListItem(user, !pendingForRemoval)) + } + + if (multiAccountSupport(this)) { + userListItems.add(UserListItem()) + } + + return userListItems + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + var result = true + + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + } else { + result = super.onOptionsItemSelected(item) + } + + return result + } + + override fun showFirstRunActivity() { + val intent = Intent(applicationContext, FirstRunActivity::class.java).apply { + putExtra(FirstRunActivity.EXTRA_ALLOW_CLOSE, true) + } + startActivity(intent) + } + + @Suppress("TooGenericExceptionCaught") + @SuppressLint("NotifyDataSetChanged") + override fun startAccountCreation() { + val am = AccountManager.get(applicationContext) + am.addAccount( + MainApp.getAccountType(this), + null, + null, + null, + this, + { future: AccountManagerFuture? -> + if (future != null) { + try { + val result = future.result + val name = result.getString(AccountManager.KEY_ACCOUNT_NAME) + accountManager.setCurrentOwnCloudAccount(name) + userListAdapter = UserListAdapter( + this, + accountManager, + userListItems, + this, + multipleAccountsSupported, + false, + true, + viewThemeUtils + ) + recyclerView?.adapter = userListAdapter + runOnUiThread { userListAdapter?.notifyDataSetChanged() } + } catch (e: OperationCanceledException) { + Log_OC.d(TAG, "Account creation canceled") + } catch (e: Exception) { + Log_OC.e(TAG, "Account creation finished in exception: ", e) + } + } + }, + handler + ) + } + + @SuppressLint("NotifyDataSetChanged") + @Subscribe(threadMode = ThreadMode.MAIN) + override fun onAccountRemovedEvent(event: AccountRemovedEvent) { + val userListItemArray = userListItems + userListAdapter?.clear() + userListAdapter?.addAll(userListItemArray) + userListAdapter?.notifyDataSetChanged() + } + + override fun run(future: AccountManagerFuture) { + if (!future.isDone) { + return + } + + // after remove account + accountName?.let { + val user = accountManager.getUser(it) + + if (!user.isPresent) { + fileUploadHelper.cancel(it) + cancelAllDownloadsForAccount() + } + } + + val currentUser = userAccountManager.user + if (currentUser.isAnonymous) { + var accountName = "" + val users = accountManager.allUsers + if (users.size > 0) { + accountName = users[0].accountName + } + accountManager.setCurrentOwnCloudAccount(accountName) + } + + val userListItemArray = userListItems + if (userListItemArray.size > SINGLE_ACCOUNT) { + userListAdapter = UserListAdapter( + this, + accountManager, + userListItemArray, + this, + multipleAccountsSupported, + false, + true, + viewThemeUtils + ) + recyclerView?.adapter = userListAdapter + } else { + onBackPressedDispatcher.onBackPressed() + } + } + + override fun getHandler(): Handler = handler + + override fun getOperationsServiceBinder(): OperationsServiceBinder? = null + + override fun getStorageManager(): FileDataStorageManager = super.getStorageManager() + + override fun getFileOperationsHelper(): FileOperationsHelper? = null + + @Suppress("DEPRECATION") + @SuppressLint("NotifyDataSetChanged") + private fun performAccountRemoval(user: User) { + val itemCount = userListAdapter?.itemCount ?: 0 + + // disable account in recycler view + for (i in 0 until itemCount) { + val item = userListAdapter?.getItem(i) + + if (item != null && item.user.accountName.equals(user.accountName, ignoreCase = true)) { + item.isEnabled = false + break + } + + userListAdapter?.notifyDataSetChanged() + } + + // store pending account removal + val arbitraryDataProvider: ArbitraryDataProvider = ArbitraryDataProviderImpl(this) + arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, PENDING_FOR_REMOVAL, true.toString()) + + cancelAllDownloadsForAccount() + fileUploadHelper.cancel(user.accountName) + backgroundJobManager.startAccountRemovalJob(user.accountName, false) + + // immediately select a new account + val users = accountManager.allUsers + + var newAccountName = "" + for (u in users) { + if (!u.accountName.equals(u.accountName, ignoreCase = true)) { + newAccountName = u.accountName + break + } + } + + if (newAccountName.isEmpty()) { + Log_OC.d(TAG, "new account set to null") + accountManager.resetOwnCloudAccount() + } else { + Log_OC.d(TAG, "new account set to: $newAccountName") + accountManager.setCurrentOwnCloudAccount(newAccountName) + } + + // only one to be (deleted) account remaining + if (users.size < MIN_MULTI_ACCOUNT_SIZE) { + val resultIntent = Intent() + resultIntent.putExtra(KEY_ACCOUNT_LIST_CHANGED, true) + resultIntent.putExtra(KEY_CURRENT_ACCOUNT_CHANGED, true) + setResult(RESULT_OK, resultIntent) + onBackPressedDispatcher.onBackPressed() + } + } + + private fun cancelAllDownloadsForAccount() { + workerAccountName?.let { accountName -> + workerCurrentDownloadAccountName?.let { currentDownloadAccountName -> + if (workerFileId != -1L) { + FileDownloadHelper.instance().cancelAllDownloadsForAccount( + accountName, + currentDownloadAccountName, + workerFileId + ) + } + } + } + } + + @Suppress("DEPRECATION") + private fun openAccount(user: User) { + val intent = Intent(this, UserInfoActivity::class.java).apply { + putExtra(UserInfoActivity.KEY_ACCOUNT, user) + + val oca = user.toOwnCloudAccount() + putExtra(UserListAdapter.KEY_DISPLAY_NAME, oca.displayName) + } + + startActivityForResult(intent, UserListAdapter.KEY_USER_INFO_REQUEST_CODE) + } + + @Suppress("DEPRECATION") + @VisibleForTesting + fun showUser(user: User, userInfo: UserInfo?) { + val intent = Intent(this, UserInfoActivity::class.java).apply { + val oca = user.toOwnCloudAccount() + putExtra(UserInfoActivity.KEY_ACCOUNT, user) + putExtra(UserListAdapter.KEY_DISPLAY_NAME, oca.displayName) + putExtra(UserInfoActivity.KEY_USER_DATA, userInfo) + } + + startActivityForResult(intent, UserListAdapter.KEY_USER_INFO_REQUEST_CODE) + } + + override fun onOptionItemClicked(user: User, view: View) { + if (view.id == R.id.account_menu) { + val popup = PopupMenu(this, view) + popup.menuInflater.inflate(R.menu.item_account, popup.menu) + + if (accountManager.user == user) { + popup.menu.findItem(R.id.action_open_account).setVisible(false) + } + + popup.setOnMenuItemClickListener { item: MenuItem -> + val itemId = item.itemId + when (itemId) { + R.id.action_open_account -> { + accountClicked(user) + } + + R.id.action_delete_account -> { + openAccountRemovalDialog(user, supportFragmentManager) + } + + else -> { + openAccount(user) + } + } + true + } + + popup.show() + } else { + openAccount(user) + } + } + + override fun onStart() { + val downloadFileStartedIntentFilter = IntentFilter(FileDownloadEventBroadcaster.ACTION_DOWNLOAD_ENQUEUED) + localBroadcastManager.registerReceiver(fileDownloadStartedReceiver, downloadFileStartedIntentFilter) + super.onStart() + } + + override fun onStop() { + localBroadcastManager.unregisterReceiver(fileDownloadStartedReceiver) + super.onStop() + } + + override fun onAccountClicked(user: User) { + openAccount(user) + } + + private class FileDownloadStartedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log_OC.d(TAG, "download received") + + workerAccountName = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_ACCOUNT_NAME) + workerCurrentDownloadAccountName = + intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_CURRENT_DOWNLOAD_ACCOUNT_NAME) + workerFileId = intent.getLongExtra(FileDownloadEventBroadcaster.EXTRA_CURRENT_DOWNLOAD_FILE_ID, -1L) + } + } + + companion object { + private val TAG: String = ManageAccountsActivity::class.java.simpleName + + const val KEY_ACCOUNT_LIST_CHANGED: String = "ACCOUNT_LIST_CHANGED" + const val KEY_CURRENT_ACCOUNT_CHANGED: String = "CURRENT_ACCOUNT_CHANGED" + const val PENDING_FOR_REMOVAL: String = UserAccountManager.PENDING_FOR_REMOVAL + + private const val KEY_DELETE_CODE = 101 + private const val SINGLE_ACCOUNT = 1 + private const val MIN_MULTI_ACCOUNT_SIZE = 2 + + private var workerAccountName: String? = null + private var workerCurrentDownloadAccountName: String? = null + private var workerFileId: Long = -1L + + private fun toAccountNames(users: Collection): Set { + val accountNames: MutableSet = Sets.newHashSetWithExpectedSize(users.size) + for (user in users) { + accountNames.add(user.accountName) + } + return accountNames + } + + fun openAccountRemovalDialog(user: User, fragmentManager: FragmentManager) { + val dialog = newInstance(user) + dialog.show(fragmentManager, "dialog") + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ManageSpaceActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ManageSpaceActivity.kt new file mode 100644 index 000000000000..674a7d1dab62 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ManageSpaceActivity.kt @@ -0,0 +1,163 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.work.WorkManager +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.common.ui.util.extensions.applyEdgeToEdgeWithSystemBarPadding +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.R +import com.owncloud.android.databinding.ActivityManageSpaceBinding +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject +import kotlin.system.exitProcess + +class ManageSpaceActivity : + AppCompatActivity(), + Injectable { + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var userAccountManager: UserAccountManager + + private lateinit var binding: ActivityManageSpaceBinding + + override fun onCreate(savedInstanceState: Bundle?) { + applyEdgeToEdgeWithSystemBarPadding() + super.onCreate(savedInstanceState) + + binding = ActivityManageSpaceBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.run { + manageActivityToolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + generalDescription.text = getString(R.string.manage_space_description, getString(R.string.app_name)) + clearDataButton.setOnClickListener { + lifecycleScope.launch { + clearData() + } + } + } + } + + @Suppress("MagicNumber") + private suspend fun clearData() { + withContext(Dispatchers.IO) { + // cancel all works + WorkManager.getInstance(this@ManageSpaceActivity).cancelAllWork() + + val lockPref = preferences.lockPreference + val passCodeEnable = SettingsActivity.LOCK_PASSCODE == lockPref + var passCodeDigits = arrayOfNulls(4) + if (passCodeEnable) { + passCodeDigits = preferences.passCode + } + + // Clear preferences data + preferences.clear() + + // Recover passcode + if (passCodeEnable) { + preferences.setPassCode( + passCodeDigits[0], + passCodeDigits[1], + passCodeDigits[2], + passCodeDigits[3] + ) + } + preferences.lockPreference = lockPref + userAccountManager.removeAllAccounts() + + // Clear app data + val result = clearApplicationData() + withContext(Dispatchers.Main) { + if (result) { + finishAffinity() + finishAndRemoveTask() + exitProcess(0) + } else { + Snackbar.make( + findViewById(android.R.id.content), + R.string.manage_space_clear_data, + Snackbar.LENGTH_LONG + ).show() + } + } + } + } + + @Suppress("NestedBlockDepth") + private fun clearApplicationData(): Boolean { + var clearResult = true + + cacheDir.parent?.let { parentCacheDirPath -> + val appDir = File(parentCacheDirPath) + if (appDir.exists()) { + val children = appDir.list() + if (children != null) { + children.filter { it != LIB_FOLDER }.forEach { s -> + val fileToDelete = File(appDir, s) + clearResult = clearResult && deleteDir(fileToDelete) + Log_OC.d(TAG, "Clear Application Data, File: " + fileToDelete.name + " DELETED *****") + } + } else { + clearResult = false + } + } + } + + return clearResult + } + + @Suppress("ReturnCount") + private fun deleteDir(dir: File?): Boolean { + if (dir != null && dir.isDirectory) { + dir.list()?.forEach { child -> + val success = deleteDir(File(dir, child)) + if (!success) { + Log_OC.w(TAG, "File NOT deleted $child") + return false + } else { + Log_OC.d(TAG, "File deleted $child") + } + } ?: return false + } + return dir?.delete() ?: false + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + finish() + true + } + + else -> { + Log_OC.w(TAG, "Unknown menu item triggered") + super.onOptionsItemSelected(item) + } + } + + companion object { + private val TAG = ManageSpaceActivity::class.java.simpleName + private const val LIB_FOLDER = "lib" + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/OnEnforceableRefreshListener.java b/app/src/main/java/com/owncloud/android/ui/activity/OnEnforceableRefreshListener.java new file mode 100644 index 000000000000..5eaa65376103 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/OnEnforceableRefreshListener.java @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 Jose Antonio Barros Ramos + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +public interface OnEnforceableRefreshListener extends SwipeRefreshLayout.OnRefreshListener { + + void onRefresh(boolean enforced); +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/OnFilesRemovedListener.kt b/app/src/main/java/com/owncloud/android/ui/activity/OnFilesRemovedListener.kt new file mode 100644 index 000000000000..c843acc0d01c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/OnFilesRemovedListener.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.activity + +import com.nextcloud.client.database.entity.SyncedFolderEntity +import com.owncloud.android.datamodel.OCFile + +interface OnFilesRemovedListener { + fun onFilesRemoved() + fun onAutoUploadFolderRemoved( + entities: List, + filesToRemove: List, + onlyLocalCopy: Boolean + ) +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/PassCodeActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/PassCodeActivity.kt new file mode 100644 index 000000000000..641945b43448 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/PassCodeActivity.kt @@ -0,0 +1,493 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2011 Bartek Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.View +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.R +import com.owncloud.android.authentication.PassCodeManager +import com.owncloud.android.databinding.PasscodelockBinding +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.components.PassCodeEditText +import com.owncloud.android.ui.components.showKeyboard +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("TooManyFunctions", "MagicNumber") +class PassCodeActivity : + AppCompatActivity(), + Injectable { + + companion object { + private val TAG = PassCodeActivity::class.java.simpleName + + private const val KEY_PASSCODE_DIGITS = "PASSCODE_DIGITS" + private const val KEY_CONFIRMING_PASSCODE = "CONFIRMING_PASSCODE" + const val ACTION_REQUEST_WITH_RESULT = "ACTION_REQUEST_WITH_RESULT" + const val ACTION_CHECK_WITH_RESULT = "ACTION_CHECK_WITH_RESULT" + const val ACTION_CHECK = "ACTION_CHECK" + const val KEY_PASSCODE = "KEY_PASSCODE" + const val KEY_CHECK_RESULT = "KEY_CHECK_RESULT" + const val PREFERENCE_PASSCODE_D = "PrefPinCode" + const val PREFERENCE_PASSCODE_D1 = "PrefPinCode1" + const val PREFERENCE_PASSCODE_D2 = "PrefPinCode2" + const val PREFERENCE_PASSCODE_D3 = "PrefPinCode3" + const val PREFERENCE_PASSCODE_D4 = "PrefPinCode4" + } + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var passCodeManager: PassCodeManager + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @get:VisibleForTesting + lateinit var binding: PasscodelockBinding + private set + + private val passCodeEditTexts = arrayOfNulls(4) + private var passCodeDigits: Array = arrayOf("", "", "", "") + private var confirmingPassCode = false + private var changed = true // to control that only one blocks jump + private var delayInSeconds = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + delayInSeconds = preferences.passCodeDelay + binding = PasscodelockBinding.inflate(layoutInflater) + setContentView(binding.root) + + PassCodeManager.setSecureFlag(this, true) + applyTint() + setupPasscodeEditTexts() + setSoftInputMode() + setupUI(savedInstanceState) + setTextListeners() + } + + private fun applyTint() { + viewThemeUtils.platform.colorViewBackground(binding.cardViewContent, ColorRole.SURFACE_VARIANT) + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.cancel) + } + + private fun setupPasscodeEditTexts() { + passCodeEditTexts[0] = binding.txt0 + passCodeEditTexts[1] = binding.txt1 + passCodeEditTexts[2] = binding.txt2 + passCodeEditTexts[3] = binding.txt3 + + passCodeEditTexts.forEach { + it?.let { editText -> + viewThemeUtils.platform.colorEditText(editText) + } + } + + passCodeEditTexts[0]?.requestFocus() + + binding.cardViewContent.setOnClickListener { + binding.txt0.showKeyboard() + } + } + + private fun setSoftInputMode() { + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + } + + private fun setupUI(savedInstanceState: Bundle?) { + if (ACTION_CHECK == intent.action) { + // / this is a pass code request; the user has to input the right value + binding.header.setText(R.string.pass_code_enter_pass_code) + binding.explanation.visibility = View.INVISIBLE + setCancelButtonEnabled(false) // no option to cancel + showDelay() + } else if (ACTION_REQUEST_WITH_RESULT == intent.action) { + if (savedInstanceState != null) { + confirmingPassCode = savedInstanceState.getBoolean(KEY_CONFIRMING_PASSCODE) + passCodeDigits = savedInstanceState.getStringArray(KEY_PASSCODE_DIGITS) ?: arrayOf("", "", "", "") + } + if (confirmingPassCode) { + // the app was in the passcode confirmation + requestPassCodeConfirmation() + } else { + // pass code preference has just been activated in SettingsActivity; + // will receive and confirm pass code value + binding.header.setText(R.string.pass_code_configure_your_pass_code) + binding.explanation.visibility = View.VISIBLE + } + setCancelButtonEnabled(true) + } else if (ACTION_CHECK_WITH_RESULT == intent.action) { + // pass code preference has just been disabled in SettingsActivity; + // will confirm user knows pass code, then remove it + binding.header.setText(R.string.pass_code_remove_your_pass_code) + binding.explanation.visibility = View.INVISIBLE + setCancelButtonEnabled(true) + } else { + throw IllegalArgumentException("A valid ACTION is needed in the Intent passed to $TAG") + } + } + + private fun setCancelButtonEnabled(enabled: Boolean) { + binding.cancel.run { + visibility = if (enabled) { + View.VISIBLE + } else { + View.INVISIBLE + } + + setOnClickListener { + if (enabled) { + finish() + } + } + } + } + + private fun setTextListeners() { + for (i in passCodeEditTexts.indices) { + val editText = passCodeEditTexts[i] + val isLast = (i == 3) + + editText?.addTextChangedListener(PassCodeDigitTextWatcher(i, isLast)) + + if (i > 0) { + setOnKeyListener(i) + } + + editText?.onFocusChangeListener = View.OnFocusChangeListener { _: View?, _: Boolean -> + onPassCodeEditTextFocusChange(i) + } + } + } + + private fun onPassCodeEditTextFocusChange(passCodeIndex: Int) { + for (i in 0 until passCodeIndex) { + if (TextUtils.isEmpty(passCodeEditTexts[i]?.text)) { + passCodeEditTexts[i]?.requestFocus() + break + } + } + } + + private fun setOnKeyListener(passCodeIndex: Int) { + passCodeEditTexts[passCodeIndex]?.setOnKeyListener { _: View?, keyCode: Int, _: KeyEvent? -> + if (keyCode == KeyEvent.KEYCODE_DEL && changed) { + passCodeEditTexts[passCodeIndex - 1]?.requestFocus() + + if (!confirmingPassCode) { + passCodeDigits[passCodeIndex - 1] = "" + } + + passCodeEditTexts[passCodeIndex - 1]?.setText(R.string.empty) + + changed = false + } else if (!changed) { + changed = true + } + + false + } + } + + /** + * Processes the pass code entered by the user just after the last digit was in. + * + * + * Takes into account the action requested to the activity, the currently saved pass code and the previously typed + * pass code, if any. + */ + private fun processFullPassCode() { + if (ACTION_CHECK == intent.action) { + if (checkPassCode()) { + preferences.resetPinWrongAttempts() + preferences.passCodeDelay = 0 + + // / pass code accepted in request, user is allowed to access the app + passCodeManager.updateLockTimestamp() + hideKeyboard() + finish() + } else { + preferences.increasePinWrongAttempts() + showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE) + } + } else if (ACTION_CHECK_WITH_RESULT == intent.action) { + if (checkPassCode()) { + passCodeManager.updateLockTimestamp() + + val resultIntent = Intent() + resultIntent.putExtra(KEY_CHECK_RESULT, true) + setResult(RESULT_OK, resultIntent) + hideKeyboard() + finish() + } else { + showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE) + } + } else if (ACTION_REQUEST_WITH_RESULT == intent.action) { + // / enabling pass code + if (!confirmingPassCode) { + requestPassCodeConfirmation() + } else if (confirmPassCode()) { + // / confirmed: user typed the same pass code twice + savePassCodeAndExit() + } else { + showErrorAndRestart( + R.string.pass_code_mismatch, + R.string.pass_code_configure_your_pass_code, + View.VISIBLE + ) + } + } + } + + private fun hideKeyboard() { + currentFocus?.let { + val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow( + it.windowToken, + 0 + ) + } + } + + private fun showErrorAndRestart(errorMessage: Int, headerMessage: Int, explanationVisibility: Int) { + Snackbar.make(findViewById(android.R.id.content), getString(errorMessage), Snackbar.LENGTH_LONG).show() + binding.header.setText(headerMessage) // TODO check if really needed + binding.explanation.visibility = explanationVisibility // TODO check if really needed + clearBoxes() + increaseAndSaveDelayTime() + showDelay() + } + + /** + * Ask to the user for retyping the pass code just entered before saving it as the current pass code. + */ + private fun requestPassCodeConfirmation() { + clearBoxes() + binding.header.setText(R.string.pass_code_reenter_your_pass_code) + binding.explanation.visibility = View.INVISIBLE + confirmingPassCode = true + } + + private fun checkPassCode(): Boolean { + val savedPassCodeDigits = preferences.passCode + return passCodeDigits.zip(savedPassCodeDigits.orEmpty()) { input, saved -> + input == saved + }.all { it } + } + + private fun confirmPassCode(): Boolean = passCodeEditTexts.indices.all { i -> + passCodeEditTexts[i]?.text.toString() == passCodeDigits[i] + } + + private fun clearBoxes() { + passCodeEditTexts.forEach { it?.text?.clear() } + passCodeEditTexts.firstOrNull()?.requestFocus() + } + + /** + * Overrides click on the BACK arrow to correctly cancel ACTION_ENABLE or ACTION_DISABLE, while preventing than + * ACTION_CHECK may be worked around. + * + * @param keyCode Key code of the key that triggered the down event. + * @param event Event triggered. + * @return 'True' when the key event was processed by this method. + */ + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK && event.repeatCount == 0) { + if (ACTION_CHECK == intent.action) { + moveTaskToBack(true) + finishAndRemoveTask() + } else if (ACTION_REQUEST_WITH_RESULT == intent.action || ACTION_CHECK_WITH_RESULT == intent.action) { + finish() + } // else, do nothing, but report that the key was consumed to stay alive + return true + } + return super.onKeyDown(keyCode, event) + } + + private fun savePassCodeAndExit() { + val resultIntent = Intent() + resultIntent.putExtra( + KEY_PASSCODE, + passCodeDigits[0] + passCodeDigits[1] + passCodeDigits[2] + passCodeDigits[3] + ) + setResult(RESULT_OK, resultIntent) + passCodeManager.updateLockTimestamp() + finish() + } + + @Suppress("MagicNumber") + private fun increaseAndSaveDelayTime() { + val maxDelayTimeInSeconds = 300 + val delayIncrementation = 15 + + if (delayInSeconds < maxDelayTimeInSeconds) { + delayInSeconds += delayIncrementation + preferences.passCodeDelay = delayInSeconds + preferences.increasePinWrongAttempts() + } + } + + @Suppress("MagicNumber") + private fun getExplanationText(timeInSecond: Int): String = when { + timeInSecond < 60 -> resources.getQuantityString( + R.plurals.delay_message, + timeInSecond, + timeInSecond + ) + + else -> { + val minutes = timeInSecond / 60 + val remainingSeconds = timeInSecond % 60 + + when { + remainingSeconds == 0 -> resources.getQuantityString( + R.plurals.delay_message_minutes, + minutes, + minutes + ) + + else -> { + val minuteText = resources.getQuantityString( + R.plurals.delay_message_minutes_part, + minutes, + minutes + ) + val secondText = resources.getQuantityString( + R.plurals.delay_message_seconds_part, + remainingSeconds, + remainingSeconds + ) + + val prefixText = "$minuteText $secondText" + getString(R.string.due_to_too_many_wrong_attempts, prefixText) + } + } + } + } + + @Suppress("MagicNumber") + private fun showDelay() { + val pinBruteForceCount = preferences.pinBruteForceDelay() + if (pinBruteForceCount <= 0) { + return + } + + enableInputFields(false) + + var counter = delayInSeconds + lifecycleScope.launch(Dispatchers.Main) { + while (counter != 0) { + binding.explanation.text = getExplanationText(counter) + delay(1000) + counter -= 1 + } + + enableInputFields(true) + focusFirstInputField() + } + } + + private fun enableInputFields(enabled: Boolean) { + binding.run { + explanation.setVisibleIf(!enabled) + txt0.isEnabled = enabled + txt1.isEnabled = enabled + txt2.isEnabled = enabled + txt3.isEnabled = enabled + } + } + + private fun focusFirstInputField() { + binding.run { + txt0.requestFocus() + txt0.showKeyboard() + } + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.run { + putBoolean(KEY_CONFIRMING_PASSCODE, confirmingPassCode) + putStringArray(KEY_PASSCODE_DIGITS, passCodeDigits) + } + } + + override fun onDestroy() { + PassCodeManager.setSecureFlag(this, false) + super.onDestroy() + } + + private inner class PassCodeDigitTextWatcher(index: Int, lastOne: Boolean) : TextWatcher { + private var mIndex = -1 + private val mLastOne: Boolean + + init { + mIndex = index + mLastOne = lastOne + + require(mIndex >= 0) { + "Invalid index in " + PassCodeDigitTextWatcher::class.java.simpleName + + " constructor" + } + } + + private operator fun next(): Int = if (mLastOne) 0 else mIndex + 1 + + /** + * Performs several actions when the user types a digit in an input field: - saves the input digit to the state + * of the activity; this will allow retyping the pass code to confirm it. - moves the focus automatically to the + * next field - for the last field, triggers the processing of the full pass code + * + * @param s Changed text + */ + override fun afterTextChanged(s: Editable) { + if (s.isNotEmpty()) { + if (!confirmingPassCode) { + val passCodeText = passCodeEditTexts[mIndex]?.text + + if (passCodeText != null) { + passCodeDigits[mIndex] = passCodeText.toString() + } + } + + if (mLastOne) { + processFullPassCode() + } else { + passCodeEditTexts[next()]?.requestFocus() + } + } else { + Log_OC.d(TAG, "Text box $mIndex was cleaned") + } + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java new file mode 100755 index 000000000000..d3a03f7a7ef5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java @@ -0,0 +1,1325 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2016-2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2016-2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2016 David A. Velasco + * SPDX-FileCopyrightText: 2016 Juan Carlos González Cabrero + * SPDX-FileCopyrightText: 2013 María Asensio Valverde + * SPDX-FileCopyrightText: 2011-2012 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorException; +import android.app.Activity; +import android.app.Dialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Resources.NotFoundException; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Parcelable; +import android.text.TextUtils; +import android.text.format.DateFormat; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager.LayoutParams; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.nextcloud.client.account.User; +import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.utils.extensions.BundleExtensionsKt; +import com.nextcloud.utils.extensions.FileExtensionsKt; +import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.nextcloud.utils.fileNameValidator.FileNameTextWatcher; +import com.nextcloud.utils.fileNameValidator.FileNameValidator; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.databinding.ReceiveExternalFilesBinding; +import com.owncloud.android.databinding.UploadFileDialogBinding; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.SyncedFolderProvider; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.operations.CreateFolderOperation; +import com.owncloud.android.operations.RefreshFolderOperation; +import com.owncloud.android.operations.UploadFileOperation; +import com.owncloud.android.syncadapter.FileSyncAdapter; +import com.owncloud.android.ui.adapter.ReceiveExternalFilesAdapter; +import com.owncloud.android.ui.asynctasks.CopyAndUploadContentUrisTask; +import com.owncloud.android.ui.dialog.AccountChooserInterface; +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment; +import com.owncloud.android.ui.dialog.CreateFolderDialogFragment; +import com.owncloud.android.ui.dialog.MultipleAccountsDialog; +import com.owncloud.android.ui.dialog.SortingOrderDialogFragment; +import com.owncloud.android.ui.fragment.TaskRetainerFragment; +import com.owncloud.android.ui.helpers.FileOperationsHelper; +import com.owncloud.android.ui.helpers.UriUploader; +import com.owncloud.android.utils.DataHolderUtil; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.ErrorMessageAdapter; +import com.owncloud.android.utils.FileSortOrder; +import com.owncloud.android.utils.MimeType; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.List; +import java.util.Objects; +import java.util.Stack; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import javax.inject.Inject; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog.Builder; +import androidx.appcompat.widget.SearchView; +import androidx.core.util.Function; +import androidx.core.view.MenuItemCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.recyclerview.widget.LinearLayoutManager; + +import static com.owncloud.android.utils.DisplayUtils.openSortingOrderDialogFragment; +import static com.owncloud.android.utils.UriUtils.getDisplayNameForUri; + +/** + * This can be used to upload things to an Nextcloud instance. + */ +public class ReceiveExternalFilesActivity extends FileActivity + implements View.OnClickListener, CopyAndUploadContentUrisTask.OnCopyTmpFilesTaskListener, + SortingOrderDialogFragment.OnSortingOrderListener, Injectable, AccountChooserInterface, + ReceiveExternalFilesAdapter.OnItemClickListener { + + private static final String TAG = ReceiveExternalFilesActivity.class.getSimpleName(); + + private static final String FTAG_ERROR_FRAGMENT = "ERROR_FRAGMENT"; + public static final String TEXT_FILE_SUFFIX = ".txt"; + public static final String URL_FILE_SUFFIX = ".url"; + public static final String WEBLOC_FILE_SUFFIX = ".webloc"; + public static final String DESKTOP_FILE_SUFFIX = ".desktop"; + public static final int SINGLE_PARENT = 1; + + @Inject AppPreferences preferences; + @Inject LocalBroadcastManager localBroadcastManager; + @Inject SyncedFolderProvider syncedFolderProvider; + + private AccountManager mAccountManager; + private Stack mParents = new Stack<>(); + @Nullable private List mStreamsToUpload; + private String mUploadPath; + private OCFile mFile; + + @Nullable + private Function mFileDisplayNameTransformer = null; + + private SyncBroadcastReceiver mSyncBroadcastReceiver; + private ReceiveExternalFilesAdapter receiveExternalFilesAdapter; + + private final static int REQUEST_CODE__SETUP_ACCOUNT = REQUEST_CODE__LAST_SHARED + 1; + + private final static String KEY_PARENTS = "PARENTS"; + private final static String KEY_FILE = "FILE"; + + private boolean mUploadFromTmpFile; + private String mSubjectText; + private String mExtraText; + + private final static Charset FILENAME_ENCODING = Charset.forName("UTF-8"); + + private ViewGroup mEmptyListContainer; + private TextView mEmptyListMessage; + private TextView mEmptyListHeadline; + private ImageView mEmptyListIcon; + private MaterialButton sortButton; + private ReceiveExternalFilesBinding binding; + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + if (savedInstanceState != null) { + String parentPath = savedInstanceState.getString(KEY_PARENTS); + + if (parentPath != null) { + mParents.addAll(Arrays.asList(parentPath.split(OCFile.PATH_SEPARATOR))); + } + + mFile = BundleExtensionsKt.getParcelableArgument(savedInstanceState, KEY_FILE, OCFile.class); + } + mAccountManager = (AccountManager) getSystemService(Context.ACCOUNT_SERVICE); + + super.onCreate(savedInstanceState); + binding = ReceiveExternalFilesBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + prepareStreamsToUpload(); + + // Listen for sync messages + IntentFilter syncIntentFilter = new IntentFilter(RefreshFolderOperation. + EVENT_SINGLE_FOLDER_CONTENTS_SYNCED); + syncIntentFilter.addAction(RefreshFolderOperation.EVENT_SINGLE_FOLDER_SHARES_SYNCED); + mSyncBroadcastReceiver = new SyncBroadcastReceiver(); + localBroadcastManager.registerReceiver(mSyncBroadcastReceiver, syncIntentFilter); + + // Init Fragment without UI to retain AsyncTask across configuration changes + FragmentManager fm = getSupportFragmentManager(); + TaskRetainerFragment taskRetainerFragment = + (TaskRetainerFragment) fm.findFragmentByTag(TaskRetainerFragment.FTAG_TASK_RETAINER_FRAGMENT); + if (taskRetainerFragment == null) { + taskRetainerFragment = new TaskRetainerFragment(); + fm.beginTransaction() + .add(taskRetainerFragment, TaskRetainerFragment.FTAG_TASK_RETAINER_FRAGMENT).commit(); + } // else, Fragment already created and retained across configuration change + + handleBackPress(); + } + + @Override + protected void setAccount(Account account, boolean savedAccount) { + Account[] accounts = mAccountManager.getAccountsByType(MainApp.getAccountType(this)); + if (accounts.length == 0) { + Log_OC.i(TAG, "No ownCloud account is available"); + DialogNoAccount dialog = new DialogNoAccount(viewThemeUtils); + dialog.show(getSupportFragmentManager(), null); + } + + if (!somethingToUpload()) { + showErrorDialog( + R.string.uploader_error_message_no_file_to_upload, + R.string.uploader_error_title_no_file_to_upload + ); + } + + super.setAccount(account, savedAccount); + } + + private void showAccountChooserDialog() { + MultipleAccountsDialog dialog = new MultipleAccountsDialog(); + dialog.show(getSupportFragmentManager(), null); + } + + private Activity getActivity() { + return this; + } + + @Override + public void onAccountChosen(@NonNull User user) { + setAccount(user.toPlatformAccount(), false); + initTargetFolder(); + populateDirectoryList(null); + } + + @Override + protected void onStart() { + super.onStart(); + + if (mAccountManager.getAccountsByType(MainApp.getAccountType(this)).length == 0) { + final var message = String.format(getString(R.string.uploader_wrn_no_account_text), + getString(R.string.app_name)); + DisplayUtils.showSnackMessage(this, message); + return; + } + + initTargetFolder(); + browseToFolderIfItExists(); + } + + private void browseToFolderIfItExists() { + String full_path = generatePath(mParents); + final OCFile fileByPath = getStorageManager().getFileByPath(full_path); + if (fileByPath != null) { + startSyncFolderOperation(fileByPath); + populateDirectoryList(null); + } else { + browseToRoot(); + preferences.setLastUploadPath(OCFile.ROOT_PATH); + } + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + FileExtensionsKt.logFileSize(mFile, TAG); + super.onSaveInstanceState(outState); + outState.putString(KEY_PARENTS, generatePath(mParents)); + outState.putParcelable(KEY_FILE, mFile); + if (getUser().isPresent()) { + outState.putParcelable(FileActivity.EXTRA_USER, getUser().orElseThrow(RuntimeException::new)); + } + + Log_OC.d(TAG, "onSaveInstanceState() end"); + } + + @Override + protected void onDestroy() { + if (mSyncBroadcastReceiver != null) { + localBroadcastManager.unregisterReceiver(mSyncBroadcastReceiver); + } + + executorService.shutdown(); + super.onDestroy(); + } + + @Override + public void onSortingOrderChosen(FileSortOrder newSortOrder) { + preferences.setSortOrder(mFile, newSortOrder); + sortButton.setText(DisplayUtils.getSortOrderStringId(newSortOrder)); + populateDirectoryList(null); + } + + @Override + public void selectFile(OCFile file) { + if (file.isFolder()) { + final var optionalCapabilities = getCapabilities(); + if (optionalCapabilities.isEmpty()) { + return; + } + + String filenameErrorMessage = FileNameValidator.INSTANCE.checkFileName(file.getFileName(), optionalCapabilities.get(), this, null); + if (filenameErrorMessage != null) { + DisplayUtils.showSnackMessage(this, filenameErrorMessage); + return; + } + + if (file.isEncrypted() && + !FileOperationsHelper.isEndToEndEncryptionSetup(this, getUser().orElseThrow(IllegalAccessError::new))) { + DisplayUtils.showSnackMessage(this, R.string.e2e_not_yet_setup); + + return; + } + + startSyncFolderOperation(file); + + String filename = fileDataStorageManager.getFileNameBasedOnEncryptionStatus(file); + mParents.push(filename); + populateDirectoryList(file); + } + } + + public static class DialogNoAccount extends DialogFragment { + private final ViewThemeUtils viewThemeUtils; + + public DialogNoAccount(ViewThemeUtils viewThemeUtils) { + this.viewThemeUtils = viewThemeUtils; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()); + builder.setIcon(R.drawable.ic_warning); + builder.setTitle(R.string.uploader_wrn_no_account_title); + builder.setMessage(String.format(getString(R.string.uploader_wrn_no_account_text), + getString(R.string.app_name))); + builder.setCancelable(false); + builder.setPositiveButton(R.string.uploader_wrn_no_account_setup_btn_text, (dialog, which) -> { + // using string value since in API7 this + // constant is not defined + // in API7 < this constant is defined in + // Settings.ADD_ACCOUNT_SETTINGS + // and Settings.EXTRA_AUTHORITIES + Intent intent = new Intent(android.provider.Settings.ACTION_ADD_ACCOUNT); + intent.putExtra("authorities", new String[]{MainApp.getAuthTokenType()}); + startActivityForResult(intent, REQUEST_CODE__SETUP_ACCOUNT); + }); + builder.setNeutralButton(R.string.uploader_wrn_no_account_quit_btn_text, + (dialog, which) -> requireActivity().finish()); + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(requireContext(), builder); + return builder.create(); + } + } + + public static class DialogInputUploadFilename extends DialogFragment implements Injectable { + private static final String KEY_SUBJECT_TEXT = "SUBJECT_TEXT"; + private static final String KEY_EXTRA_TEXT = "EXTRA_TEXT"; + + private static final int CATEGORY_URL = 1; + private static final int CATEGORY_MAPS_URL = 2; + private static final int EXTRA_TEXT_LENGTH = 3; + private static final int SINGLE_SPINNER_ENTRY = 1; + + private List mFilenameBase; + private List mFilenameSuffix; + private List mText; + private int mFileCategory; + + private Spinner mSpinner; + @Inject AppPreferences preferences; + @Inject ViewThemeUtils viewThemeUtils; + + public static DialogInputUploadFilename newInstance(String subjectText, String extraText) { + DialogInputUploadFilename dialog = new DialogInputUploadFilename(); + Bundle args = new Bundle(); + args.putString(KEY_SUBJECT_TEXT, subjectText); + args.putString(KEY_EXTRA_TEXT, extraText); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + mFilenameBase = new ArrayList<>(); + mFilenameSuffix = new ArrayList<>(); + mText = new ArrayList<>(); + + String subjectText = ""; + String extraText = ""; + if (getArguments() != null) { + if (getArguments().getString(KEY_SUBJECT_TEXT) != null) { + subjectText = getArguments().getString(KEY_SUBJECT_TEXT); + } + if (getArguments().getString(KEY_EXTRA_TEXT) != null) { + extraText = getArguments().getString(KEY_EXTRA_TEXT); + } + } + + LayoutInflater inflater = getLayoutInflater(); + final UploadFileDialogBinding binding = UploadFileDialogBinding.inflate(inflater); + + ArrayAdapter adapter + = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + int selectPos = 0; + String filename = renameSafeFilename(subjectText); + if (filename == null) { + filename = ""; + } + adapter.add(getString(R.string.upload_file_dialog_filetype_snippet_text)); + mText.add(extraText); + mFilenameBase.add(filename); + mFilenameSuffix.add(TEXT_FILE_SUFFIX); + if (isIntentStartWithUrl(extraText)) { + String str = getString(R.string.upload_file_dialog_filetype_internet_shortcut); + mText.add(internetShortcutUrlText(extraText)); + mFilenameBase.add(filename); + mFilenameSuffix.add(URL_FILE_SUFFIX); + adapter.add(String.format(str, URL_FILE_SUFFIX)); + + mText.add(internetShortcutWeblocText(extraText)); + mFilenameBase.add(filename); + mFilenameSuffix.add(WEBLOC_FILE_SUFFIX); + adapter.add(String.format(str, WEBLOC_FILE_SUFFIX)); + + mText.add(internetShortcutDesktopText(extraText, filename)); + mFilenameBase.add(filename); + mFilenameSuffix.add(DESKTOP_FILE_SUFFIX); + adapter.add(String.format(str, DESKTOP_FILE_SUFFIX)); + + selectPos = preferences.getUploadUrlFileExtensionUrlSelectedPos(); + mFileCategory = CATEGORY_URL; + } else if (isIntentFromGoogleMap(subjectText, extraText)) { + String str = getString(R.string.upload_file_dialog_filetype_googlemap_shortcut); + String[] texts = extraText.split("\n"); + mText.add(internetShortcutUrlText(texts[2])); + mFilenameBase.add(texts[0]); + mFilenameSuffix.add(URL_FILE_SUFFIX); + adapter.add(String.format(str, URL_FILE_SUFFIX)); + + mText.add(internetShortcutWeblocText(texts[2])); + mFilenameBase.add(texts[0]); + mFilenameSuffix.add(WEBLOC_FILE_SUFFIX); + adapter.add(String.format(str, WEBLOC_FILE_SUFFIX)); + + mText.add(internetShortcutDesktopText(texts[2], texts[0])); + mFilenameBase.add(texts[0]); + mFilenameSuffix.add(DESKTOP_FILE_SUFFIX); + adapter.add(String.format(str, DESKTOP_FILE_SUFFIX)); + + selectPos = preferences.getUploadMapFileExtensionUrlSelectedPos(); + mFileCategory = CATEGORY_MAPS_URL; + } + + setFilename(binding.userInput, selectPos); + binding.userInput.requestFocus(); + viewThemeUtils.material.colorTextInputLayout(binding.userInputContainer); + + setupSpinner(adapter, selectPos, binding.userInput, binding.fileType); + if (adapter.getCount() == SINGLE_SPINNER_ENTRY) { + binding.labelFileType.setVisibility(View.GONE); + binding.fileType.setVisibility(View.GONE); + } + mSpinner = binding.fileType; + + Dialog filenameDialog = createFilenameDialog(binding.getRoot(), binding.userInput, binding.fileType); + if (filenameDialog.getWindow() != null) { + filenameDialog.getWindow().setSoftInputMode(LayoutParams.SOFT_INPUT_STATE_VISIBLE); + } + return filenameDialog; + } + + private void setupSpinner(ArrayAdapter adapter, int selectPos, final EditText userInput, Spinner spinner) { + spinner.setAdapter(adapter); + spinner.setSelection(selectPos, false); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + Spinner spinner = (Spinner) parent; + int selectPos = spinner.getSelectedItemPosition(); + setFilename(userInput, selectPos); + saveSelection(selectPos); + } + + @Override + public void onNothingSelected(AdapterView parent) { + // nothing to do + } + }); + } + + @NonNull + private Dialog createFilenameDialog(View view, final EditText userInput, final Spinner spinner) { + Builder builder = new Builder(requireActivity()); + builder.setView(view); + builder.setTitle(R.string.upload_file_dialog_title); + builder.setPositiveButton(R.string.common_ok, (dialog, id) -> { + int selectPos = spinner.getSelectedItemPosition(); + + // verify if file name has suffix + String filename = userInput.getText().toString(); + String suffix = mFilenameSuffix.get(selectPos); + if (!filename.endsWith(suffix)) { + filename += suffix; + } + + File file = createTempFile(mText.get(selectPos)); + + if (file == null) { + getActivity().finish(); + } else { + String tmpName = file.getAbsolutePath(); + + ((ReceiveExternalFilesActivity) getActivity()).uploadFile(tmpName, filename); + } + }); + builder.setNeutralButton(R.string.common_cancel, (dialog, id) -> dialog.cancel()); + + return builder.create(); + } + + public void onPause() { + hideSpinnerDropDown(mSpinner); + super.onPause(); + } + + private void saveSelection(int selectPos) { + switch (mFileCategory) { + case CATEGORY_URL: + preferences.setUploadUrlFileExtensionUrlSelectedPos(selectPos); + break; + case CATEGORY_MAPS_URL: + preferences.setUploadMapFileExtensionUrlSelectedPos(selectPos); + break; + default: + Log_OC.d(TAG, "Simple text snippet only: no selection to be persisted"); + break; + } + } + + private void hideSpinnerDropDown(Spinner spinner) { + try { + Method method = Spinner.class.getDeclaredMethod("onDetachedFromWindow"); + method.setAccessible(true); + method.invoke(spinner); + } catch (Exception e) { + Log_OC.e(TAG, "onDetachedFromWindow", e); + } + } + + private void setFilename(EditText inputText, int selectPos) { + String filename = mFilenameBase.get(selectPos) + mFilenameSuffix.get(selectPos); + inputText.setText(filename); + int selectionStart = 0; + int extensionStart = filename.lastIndexOf('.'); + int selectionEnd = extensionStart >= 0 ? extensionStart : filename.length(); + if (selectionEnd >= 0) { + inputText.setSelection( + Math.min(selectionStart, selectionEnd), + Math.max(selectionStart, selectionEnd)); + } + } + + private boolean isIntentFromGoogleMap(String subjectText, String extraText) { + String[] texts = extraText.split("\n"); + if (texts.length != EXTRA_TEXT_LENGTH) { + return false; + } + + if (texts[0].length() == 0 || !subjectText.equals(texts[0])) { + return false; + } + + return texts[2].startsWith("https://goo.gl/maps/"); + } + + private boolean isIntentStartWithUrl(String extraText) { + return extraText.startsWith("http://") || extraText.startsWith("https://"); + } + + @Nullable + private String renameSafeFilename(String filename) { + String safeFilename = filename; + safeFilename = safeFilename.replaceAll("[?]", "_"); + safeFilename = safeFilename.replaceAll("\"", "_"); + safeFilename = safeFilename.replaceAll("/", "_"); + safeFilename = safeFilename.replaceAll("<", "_"); + safeFilename = safeFilename.replaceAll(">", "_"); + safeFilename = safeFilename.replaceAll("[*]", "_"); + safeFilename = safeFilename.replaceAll("[|]", "_"); + safeFilename = safeFilename.replaceAll(";", "_"); + safeFilename = safeFilename.replaceAll("=", "_"); + safeFilename = safeFilename.replaceAll(",", "_"); + + int maxLength = 128; + if (safeFilename.getBytes(FILENAME_ENCODING).length > maxLength) { + safeFilename = new String(safeFilename.getBytes(FILENAME_ENCODING), 0, maxLength, FILENAME_ENCODING); + } + return safeFilename; + } + + private String internetShortcutUrlText(String url) { + return "[InternetShortcut]\r\n" + + "URL=" + url + "\r\n"; + } + + private String internetShortcutWeblocText(String url) { + return "\n" + + "\n" + + "\n" + + "\n" + + "URL\n" + + "" + url + "\n" + + "\n" + + "\n"; + } + + private String internetShortcutDesktopText(String url, String filename) { + return "[Desktop Entry]\n" + + "Encoding=UTF-8\n" + + "Name=" + filename + "\n" + + "Type=Link\n" + + "URL=" + url + "\n" + + "Icon=text-html"; + } + + @Nullable + private File createTempFile(String text) { + final var activity = getActivity(); + if (activity == null) { + return null; + } + + final var cacheDir = activity.getCacheDir(); + + File file = new File(cacheDir, "tmp.tmp"); + + try (FileWriter fw = new FileWriter(file)) { + fw.write(text); + } catch (IOException e) { + Log_OC.d(TAG, "Error ", e); + return null; + } + + return file; + } + } + + private void handleBackPress() { + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (mParents.size() <= SINGLE_PARENT) { + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + } else { + mParents.pop(); + browseToFolderIfItExists(); + } + } + }); + } + + @Override + public void onClick(View v) { + // click on button + int id = v.getId(); + + if (id == R.id.uploader_choose_folder) { + mUploadPath = ""; // first element in mParents is root dir, represented by ""; + // init mUploadPath with "/" results in a "//" prefix + + StringBuilder stringBuilder = new StringBuilder(); + for (String p : mParents) { + stringBuilder.append(p).append(OCFile.PATH_SEPARATOR); + } + mUploadPath = stringBuilder.toString(); + + if (mUploadFromTmpFile) { + DialogInputUploadFilename dialog = DialogInputUploadFilename.newInstance(mSubjectText, mExtraText); + dialog.show(getSupportFragmentManager(), null); + } else { + Log_OC.d(TAG, "Uploading file to dir " + mUploadPath); + uploadFiles(); + } + } else if (id == R.id.uploader_cancel) { + finish(); + } else { + throw new IllegalArgumentException("Wrong element clicked"); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + Log_OC.i(TAG, "result received. req: " + requestCode + " res: " + resultCode); + if (requestCode == REQUEST_CODE__SETUP_ACCOUNT) { + if (resultCode == RESULT_CANCELED) { + finish(); + } + Account[] accounts = mAccountManager.getAccountsByType(MainApp.getAuthTokenType()); + if (accounts.length == 0) { + DialogNoAccount dialog = new DialogNoAccount(viewThemeUtils); + dialog.show(getSupportFragmentManager(), null); + } else { + // there is no need for checking for is there more then one + // account at this point + // since account setup can set only one account at time + setAccount(accounts[0], false); + populateDirectoryList(null); + } + } + } + + private void setupActionBarSubtitle() { + ActionBar actionBar = getSupportActionBar(); + + if (isHaveMultipleAccount() && actionBar != null) { + viewThemeUtils.files.themeActionBar(this, actionBar, getAccount().name); + } else if (actionBar != null) { + actionBar.setSubtitle(null); + } + } + + private void populateDirectoryList(OCFile file) { + setupEmptyList(); + setupToolbar(); + ActionBar actionBar = getSupportActionBar(); + setupActionBarSubtitle(); + + binding.toolbarLayout.sortListButtonGroup.setVisibility(View.VISIBLE); + binding.toolbarLayout.switchGridViewButton.setVisibility(View.GONE); + + String current_dir = mParents.peek(); + boolean notRoot = mParents.size() > 1; + + if (actionBar != null) { + if (TextUtils.isEmpty(current_dir)) { + viewThemeUtils.files.themeActionBar(this, actionBar, R.string.uploader_top_message); + } else { + if (file != null) { + viewThemeUtils.files.themeActionBar(this, actionBar, file.getFileName()); + } else { + viewThemeUtils.files.themeActionBar(this, actionBar, current_dir); + } + } + + actionBar.setDisplayHomeAsUpEnabled(notRoot); + actionBar.setHomeButtonEnabled(notRoot); + } + + String full_path = generatePath(mParents); + + Log_OC.d(TAG, "Populating view with content of : " + full_path); + + if (file != null) { + mFile = file; + } else { + mFile = getStorageManager().getFileByPath(full_path); + } + + if (mFile == null) { + return; + } + + List files = getStorageManager().getFolderContent(mFile, false); + + if (files.isEmpty()) { + setMessageForEmptyList(R.string.file_list_empty_headline, R.string.empty, + R.drawable.uploads); + mEmptyListContainer.setVisibility(View.VISIBLE); + binding.list.setVisibility(View.GONE); + } else { + mEmptyListContainer.setVisibility(View.GONE); + files = sortFileList(files); + setupReceiveExternalFilesAdapter(files); + } + setupFileNameInputField(); + + MaterialButton btnChooseFolder = binding.uploaderChooseFolder; + viewThemeUtils.material.colorMaterialButtonPrimaryFilled(btnChooseFolder); + btnChooseFolder.setOnClickListener(this); + + btnChooseFolder.setEnabled(mFile.canCreateFileAndFolder()); + + viewThemeUtils.platform.themeStatusBar(this); + + viewThemeUtils.material.colorMaterialButtonPrimaryOutlined(binding.uploaderCancel); + binding.uploaderCancel.setOnClickListener(this); + + sortButton = binding.toolbarLayout.sortButton; + FileSortOrder sortOrder = preferences.getSortOrderByFolder(mFile); + sortButton.setText(DisplayUtils.getSortOrderStringId(sortOrder)); + sortButton.setOnClickListener(l -> openSortingOrderDialogFragment(getSupportFragmentManager(), sortOrder)); + } + + private void setupReceiveExternalFilesAdapter(List files) { + final var optionalUser = getUser(); + if (optionalUser.isEmpty()) { + return; + } + + receiveExternalFilesAdapter = new ReceiveExternalFilesAdapter(files, + this, + optionalUser.get(), + getStorageManager(), + viewThemeUtils, + syncedFolderProvider, + this); + + + binding.list.setLayoutManager(new LinearLayoutManager(this)); + binding.list.setAdapter(receiveExternalFilesAdapter); + binding.list.setVisibility(View.VISIBLE); + } + + protected void setupEmptyList() { + mEmptyListContainer = binding.emptyView.emptyListView; + mEmptyListMessage = binding.emptyView.emptyListViewText; + mEmptyListHeadline = binding.emptyView.emptyListViewHeadline; + mEmptyListIcon = binding.emptyView.emptyListIcon; + } + + public void setMessageForEmptyList(@StringRes final int headline, @StringRes final int message, + @DrawableRes final int icon) { + new Handler(Looper.getMainLooper()).post(() -> { + if (mEmptyListContainer != null && mEmptyListMessage != null) { + mEmptyListHeadline.setText(headline); + mEmptyListMessage.setText(message); + mEmptyListIcon.setImageDrawable(viewThemeUtils.platform.tintPrimaryDrawable(this, icon)); + mEmptyListIcon.setVisibility(View.VISIBLE); + mEmptyListMessage.setVisibility(View.VISIBLE); + } + }); + } + + private void setupFileNameInputField() { + binding.userInput.setVisibility(View.GONE); + mFileDisplayNameTransformer = null; + if (mStreamsToUpload == null || mStreamsToUpload.size() != 1) { + return; + } + final String fileName = getDisplayNameForUri((Uri) mStreamsToUpload.get(0), getActivity()); + if (fileName == null) { + return; + } + final String userProvidedFileName = Objects.requireNonNullElse(binding.userInput.getText(), "").toString(); + + binding.userInput.setVisibility(View.VISIBLE); + binding.userInput.setText(userProvidedFileName.isEmpty() ? fileName : userProvidedFileName); + final var optionalCapabilities = getCapabilities(); + if (optionalCapabilities.isPresent()) { + final var validator = getFileNameTextWatcher(optionalCapabilities.get(), fileName); + binding.userInput.addTextChangedListener(validator); + } + + mFileDisplayNameTransformer = uri -> + Objects.requireNonNullElse(binding.userInput.getText(), fileName).toString(); + + // When entering the text field, pre-select the name (without extension if present), for convenient editing + binding.userInput.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + final String currentText = Objects.requireNonNullElse(binding.userInput.getText(), "").toString(); + binding.userInput.post(() -> { + if (currentText.lastIndexOf('.') != -1) { + binding.userInput.setSelection(0, currentText.lastIndexOf('.')); + } else { + // No file extension - select all + binding.userInput.selectAll(); + } + }); + } + }); + } + + @NonNull + private FileNameTextWatcher getFileNameTextWatcher(OCCapability capability, String fileName) { + return new FileNameTextWatcher( + fileName, + this, + () -> capability, + () -> receiveExternalFilesAdapter.getFileNames(), + validationError -> { + binding.userInputContainer.setError(validationError); + binding.uploaderChooseFolder.setEnabled(false); + }, + validationWarning -> { + binding.userInputContainer.setError(validationWarning); + binding.uploaderChooseFolder.setEnabled(true); + }, + () -> { // onValidationSuccess + binding.userInputContainer.setError(null); + binding.userInputContainer.setErrorEnabled(false); + binding.uploaderChooseFolder.setEnabled(true); + } + ); + } + + @Override + public void onSavedCertificate() { + startSyncFolderOperation(getCurrentDir()); + } + + private void startSyncFolderOperation(OCFile folder) { + if (folder == null) { + DisplayUtils.showSnackMessage(this, R.string.receive_external_files_activity_start_sync_folder_is_not_exists_message); + return; + } + + final var context = this; + + executorService.execute(() -> { + long currentSyncTime = System.currentTimeMillis(); + final var optionalUser = getUser(); + if (optionalUser.isEmpty()) { + DisplayUtils.showSnackMessage(this, R.string.user_information_retrieval_error); + return; + } + + final var operation = new RefreshFolderOperation(folder, + currentSyncTime, + false, + false, + getStorageManager(), + optionalUser.get(), + context + ); + + try { + operation.execute(getAccount(), context, null, null); + } catch (Exception e) { + Log_OC.d(TAG, "Exception startSyncFolderOperation: " + e); + } + }); + } + + private List sortFileList(List files) { + FileSortOrder sortOrder = preferences.getSortOrderByFolder(mFile); + boolean foldersBeforeFiles = preferences.isSortFoldersBeforeFiles(); + boolean favoritesFirst = preferences.isSortFavoritesFirst(); + return sortOrder.sortCloudFiles(files, foldersBeforeFiles, favoritesFirst); + } + + private String generatePath(Stack dirs) { + StringBuilder full_path = new StringBuilder(); + + for (String a : dirs) { + full_path.append(a).append(OCFile.PATH_SEPARATOR); + } + return full_path.toString(); + } + + private void prepareStreamsToUpload() { + Intent intent = getIntent(); + + if (intent.hasExtra(Intent.EXTRA_STREAM) && Intent.ACTION_SEND.equals(intent.getAction())) { + mStreamsToUpload = new ArrayList<>(); + mStreamsToUpload.add(IntentExtensionsKt.getParcelableArgument(intent, Intent.EXTRA_STREAM, Parcelable.class)); + } else if (intent.hasExtra(Intent.EXTRA_STREAM) && Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())) { + mStreamsToUpload = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + } else if (intent.hasExtra(Intent.EXTRA_TEXT) && Intent.ACTION_SEND.equals(intent.getAction())) { + mStreamsToUpload = null; + saveTextsFromIntent(intent); + } else { + showErrorDialog(R.string.uploader_error_message_no_file_to_upload, R.string.uploader_error_title_file_cannot_be_uploaded); + } + } + + private void saveTextsFromIntent(Intent intent) { + if (!MimeType.TEXT_PLAIN.equals(intent.getType())) { + return; + } + mUploadFromTmpFile = true; + + mSubjectText = intent.getStringExtra(Intent.EXTRA_SUBJECT); + if (mSubjectText == null) { + mSubjectText = intent.getStringExtra(Intent.EXTRA_TITLE); + if (mSubjectText == null) { + mSubjectText = DateFormat.format("yyyyMMdd_kkmmss", Calendar.getInstance()).toString(); + } + } + mExtraText = intent.getStringExtra(Intent.EXTRA_TEXT); + } + + private boolean somethingToUpload() { + return (mStreamsToUpload != null && !mStreamsToUpload.isEmpty() && mStreamsToUpload.get(0) != null || + mUploadFromTmpFile); + } + + public void uploadFile(String tmpName, String filename) { + FileUploadHelper.Companion.instance().uploadNewFiles( + getUser().orElseThrow(RuntimeException::new), + new String[]{ tmpName }, + new String[]{ mFile.getRemotePath() + filename}, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.ASK_USER); + finish(); + } + + public void uploadFiles() { + if (mStreamsToUpload == null) { + DisplayUtils.showSnackMessage(this, R.string.receive_external_files_activity_unable_to_find_file_to_upload); + return; + } + + if (mStreamsToUpload.size() > FileUploadHelper.MAX_FILE_COUNT) { + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); + return; + } + + UriUploader uploader = new UriUploader( + this, + mStreamsToUpload, + mUploadPath, + getUser().orElseThrow(RuntimeException::new), + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, + true, // Show waiting dialog while file is being copied from private storage + this, // Listener for copying to temporary files + mFileDisplayNameTransformer + ); + + UriUploader.UriUploaderResultCode resultCode = uploader.uploadUris(); + + // Save the path to shared preferences; even if upload is not possible, user chose the folder + preferences.setLastUploadPath(mUploadPath); + + if (resultCode == UriUploader.UriUploaderResultCode.OK) { + finish(); + } else { + + int messageResTitle = R.string.uploader_error_title_file_cannot_be_uploaded; + int messageResId = R.string.common_error_unknown; + + if (resultCode == UriUploader.UriUploaderResultCode.ERROR_NO_FILE_TO_UPLOAD) { + messageResId = R.string.uploader_error_message_no_file_to_upload; + messageResTitle = R.string.uploader_error_title_no_file_to_upload; + } else if (resultCode == UriUploader.UriUploaderResultCode.ERROR_READ_PERMISSION_NOT_GRANTED) { + messageResId = R.string.uploader_error_message_read_permission_not_granted; + } + + showErrorDialog(messageResId, messageResTitle); + } + } + + @Override + public void onRemoteOperationFinish(RemoteOperation operation, RemoteOperationResult result) { + super.onRemoteOperationFinish(operation, result); + + + if (operation instanceof CreateFolderOperation) { + onCreateFolderOperationFinish((CreateFolderOperation) operation, result); + } + + } + + /** + * Updates the view associated to the activity after the finish of an operation trying create a new folder + * + * @param operation Creation operation performed. + * @param result Result of the creation. + */ + private void onCreateFolderOperationFinish(CreateFolderOperation operation, RemoteOperationResult result) { + if (result.isSuccess()) { + String remotePath = operation.getRemotePath().substring(0, operation.getRemotePath().length() - 1); + String newFolder = remotePath.substring(remotePath.lastIndexOf('/') + 1); + mParents.push(newFolder); + populateDirectoryList(null); + } else { + try { + DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())); + } catch (NotFoundException e) { + Log_OC.e(TAG, "Error while trying to show fail message ", e); + } + } + } + + + /** + * Loads the target folder initialize shown to the user. + *

+ * The target account has to be chosen before this method is called. + */ + private void initTargetFolder() { + if (getStorageManager() == null) { + throw new IllegalStateException("Do not call this method before initializing mStorageManager"); + } + + if (mParents.empty()) { + String lastPath = preferences.getLastUploadPath(); + // "/" equals root-directory + if (OCFile.ROOT_PATH.equals(lastPath)) { + mParents.add(""); + } else { + String[] dir_names = lastPath.split(OCFile.PATH_SEPARATOR); + mParents.clear(); + mParents.addAll(Arrays.asList(dir_names)); + } + } + + // make sure that path still exists, if it doesn't pop the stack and try the previous path + while (!getStorageManager().fileExists(generatePath(mParents)) && mParents.size() > 1) { + mParents.pop(); + } + } + + private boolean isHaveMultipleAccount() { + return mAccountManager.getAccountsByType(MainApp.getAccountType(this)).length > 1; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.activity_receive_external_files, menu); + + if (!isHaveMultipleAccount()) { + menu.findItem(R.id.action_switch_account).setVisible(false); + menu.findItem(R.id.action_create_dir).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + } + + setupSearchView(menu); + + if (mFile != null) { + MenuItem newFolderMenuItem = menu.findItem(R.id.action_create_dir); + newFolderMenuItem.setEnabled(mFile.canCreateFileAndFolder()); + } + + return true; + } + + private void setupSearchView(Menu menu) { + final MenuItem searchMenuItem = menu.findItem(R.id.action_search); + + SearchView searchView = (SearchView) MenuItemCompat.getActionView(searchMenuItem); + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + if (receiveExternalFilesAdapter != null) { + receiveExternalFilesAdapter.filter(query); + } + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + if (receiveExternalFilesAdapter != null) { + receiveExternalFilesAdapter.filter(newText); + } + return false; + } + }); + + viewThemeUtils.androidx.themeToolbarSearchView(searchView); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + boolean retval = true; + int itemId = item.getItemId(); + + if (itemId == R.id.action_create_dir) { + CreateFolderDialogFragment dialog = CreateFolderDialogFragment.newInstance(mFile, false); + dialog.show(getSupportFragmentManager(), CreateFolderDialogFragment.CREATE_FOLDER_FRAGMENT); + } else if (itemId == android.R.id.home) { + if (mParents.size() > SINGLE_PARENT) { + getOnBackPressedDispatcher().onBackPressed(); + } + } else if (itemId == R.id.action_switch_account) { + showAccountChooserDialog(); + } else { + retval = super.onOptionsItemSelected(item); + } + + return retval; + } + + private OCFile getCurrentFolder() { + OCFile file = mFile; + if (file != null) { + if (file.isFolder()) { + return file; + } else if (getStorageManager() != null) { + return getStorageManager().getFileByPath(file.getParentRemotePath()); + } + } + return null; + } + + private void browseToRoot() { + OCFile root = getStorageManager().getFileByPath(OCFile.ROOT_PATH); + mFile = root; + mParents.clear(); + mParents.add(""); + startSyncFolderOperation(root); + } + + private class SyncBroadcastReceiver extends BroadcastReceiver { + + /** + * {@link BroadcastReceiver} to enable syncing feedback in UI + */ + @Override + public void onReceive(Context context, Intent intent) { + try { + String event = intent.getAction(); + Log_OC.d(TAG, "Received broadcast " + event); + String accountName = intent.getStringExtra(FileSyncAdapter.EXTRA_ACCOUNT_NAME); + String syncFolderRemotePath = intent.getStringExtra(FileSyncAdapter.EXTRA_FOLDER_PATH); + RemoteOperationResult syncResult = (RemoteOperationResult) + DataHolderUtil.getInstance().retrieve(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT)); + boolean sameAccount = getAccount() != null && accountName.equals(getAccount().name) + && getStorageManager() != null; + + if (sameAccount && !FileSyncAdapter.EVENT_FULL_SYNC_START.equals(event)) { + OCFile currentFile = (mFile == null) ? null : getStorageManager().getFileByPath(mFile.getRemotePath()); + OCFile currentDir = (getCurrentFolder() == null) ? null : getStorageManager().getFileByPath(getCurrentFolder().getRemotePath()); + + if (currentDir == null) { + // current folder was removed from the server + DisplayUtils.showSnackMessage(getActivity(), R.string.sync_current_folder_was_removed, getCurrentFolder().getFileName()); + browseToRoot(); + } else { + if (currentFile == null && !mFile.isFolder()) { + // currently selected file was removed in the server, and now we know it + currentFile = currentDir; + } + + if (currentDir.getRemotePath().equals(syncFolderRemotePath)) { + populateDirectoryList(currentFile); + } + } + + if (RefreshFolderOperation.EVENT_SINGLE_FOLDER_CONTENTS_SYNCED.equals(event) && syncResult != null && !syncResult.isSuccess()) { + if (syncResult.getCode() == ResultCode.UNAUTHORIZED || (syncResult.isException() && syncResult.getException() instanceof AuthenticatorException)) { + requestCredentialsUpdate(); + } else if (ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED == syncResult.getCode()) { + showUntrustedCertDialog(syncResult); + } + } + } + } catch (RuntimeException e) { + // avoid app crashes after changing the serial id of RemoteOperationResult + // in owncloud library with broadcast notifications pending to process + DataHolderUtil.getInstance().delete(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT)); + } + } + } + + /** + * Process the result of CopyAndUploadContentUrisTask + */ + @Override + public void onTmpFilesCopied(ResultCode result) { + dismissLoadingDialog(); + finish(); + } + + /** + * Show an error dialog, forcing the user to click a single button to exit the activity + * + * @param messageResId Resource id of the message to show in the dialog. + * @param messageResTitle Resource id of the title to show in the dialog. 0 to show default alert message. -1 to + * show no title. + */ + private void showErrorDialog(int messageResId, int messageResTitle) { + + ConfirmationDialogFragment errorDialog = ConfirmationDialogFragment.newInstance( + messageResId, + new String[]{getString(R.string.app_name)}, // see uploader_error_message_* in strings.xml + messageResTitle, + R.string.common_back, + -1, + -1 + ); + errorDialog.setCancelable(false); + errorDialog.setOnConfirmationListener( + new ConfirmationDialogFragment.ConfirmationDialogFragmentListener() { + @Override + public void onConfirmation(String callerTag) { + finish(); + } + + @Override + public void onNeutral(String callerTag) { + // not used at the moment + } + + @Override + public void onCancel(String callerTag) { + // not used at the moment + } + } + ); + errorDialog.show(getSupportFragmentManager(), FTAG_ERROR_FRAGMENT); + } +} + diff --git a/app/src/main/java/com/owncloud/android/ui/activity/RequestCredentialsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/RequestCredentialsActivity.java new file mode 100644 index 000000000000..6fa2f78cc603 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/RequestCredentialsActivity.java @@ -0,0 +1,100 @@ +/* + * Nextcloud Android client application + * + * @author Harikrishnan Rajan + * Copyright (C) 2017 + * Copyright (C) 2017 Nextcloud GmbH. + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + */ +package com.owncloud.android.ui.activity; + +import android.app.Activity; +import android.app.KeyguardManager; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.SystemClock; + +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.owncloud.android.R; +import com.owncloud.android.authentication.PassCodeManager; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.DeviceCredentialUtils; +import com.owncloud.android.utils.DisplayUtils; + +import androidx.annotation.Nullable; + +/** + * Dummy activity that is used to handle the device's default authentication workflow. + */ +public class RequestCredentialsActivity extends Activity { + + private static final String TAG = RequestCredentialsActivity.class.getSimpleName(); + + public final static String KEY_CHECK_RESULT = "KEY_CHECK_RESULT"; + public final static int KEY_CHECK_RESULT_TRUE = 1; + public final static int KEY_CHECK_RESULT_FALSE = 0; + public final static int KEY_CHECK_RESULT_CANCEL = -1; + private static final int REQUEST_CODE_CONFIRM_DEVICE_CREDENTIALS = 1; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + PassCodeManager.Companion.setSecureFlag(this,true); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_CODE_CONFIRM_DEVICE_CREDENTIALS) { + if (resultCode == Activity.RESULT_OK) { + AppPreferencesImpl.fromContext(this).setLockTimestamp(SystemClock.elapsedRealtime()); + finishWithResult(KEY_CHECK_RESULT_TRUE); + } else if (resultCode == Activity.RESULT_CANCELED) { + finishWithResult(KEY_CHECK_RESULT_CANCEL); + } else { + DisplayUtils.showSnackMessage(this, R.string.default_credentials_wrong); + requestCredentials(); + } + } + } + + @Override + protected void onResume() { + super.onResume(); + + if (DeviceCredentialUtils.areCredentialsAvailable(this)) { + requestCredentials(); + } else { + DisplayUtils.showSnackMessage(this, R.string.prefs_lock_device_credentials_not_setup); + finishWithResult(KEY_CHECK_RESULT_CANCEL); + } + } + + private void requestCredentials() { + KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); + if (keyguardManager != null) { + Intent i = keyguardManager.createConfirmDeviceCredentialIntent(null, null); + i.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivityForResult(i, REQUEST_CODE_CONFIRM_DEVICE_CREDENTIALS); + } else { + Log_OC.e(TAG, "Keyguard manager is null"); + finishWithResult(KEY_CHECK_RESULT_FALSE); + } + } + + private void finishWithResult(int success) { + Intent resultIntent = new Intent(); + resultIntent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + resultIntent.putExtra(KEY_CHECK_RESULT, success); + setResult(Activity.RESULT_OK, resultIntent); + finish(); + } + + @Override + protected void onDestroy() { + PassCodeManager.Companion.setSecureFlag(this,false); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt b/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt new file mode 100644 index 000000000000..18dc8a2b477a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt @@ -0,0 +1,229 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.TextUtils +import android.view.KeyEvent +import android.webkit.JavascriptInterface +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.RichDocumentsCreateAssetOperation +import com.owncloud.android.ui.asynctasks.PrintAsyncTask +import com.owncloud.android.ui.asynctasks.RichDocumentsLoadUrlTask +import com.owncloud.android.ui.fragment.OCFileListFragment +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.FileStorageUtils +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.lang.ref.WeakReference +import javax.inject.Inject + +/** + * Opens document for editing via Richdocuments app in a web view + */ +class RichDocumentsEditorWebView : EditorWebView() { + @JvmField + @Inject + var currentAccountProvider: CurrentAccountProvider? = null + + @JvmField + @Inject + var clientFactory: ClientFactory? = null + + private var activityResult: ActivityResultLauncher? = null + + @SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT_INTERFACE") + override fun postOnCreate() { + super.postOnCreate() + + webView.addJavascriptInterface(RichDocumentsMobileInterface(), "RichDocumentsMobileInterface") + + loadUrl(intent.getStringExtra(EXTRA_URL)) + + registerActivityResult() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + } + + private fun openFileChooser() { + val action = Intent(this, FilePickerActivity::class.java) + action.putExtra(OCFileListFragment.ARG_MIMETYPE, "image/") + activityResult?.launch(action) + } + + private fun registerActivityResult() { + activityResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (RESULT_OK == result.resultCode) { + result.data?.let { + handleRemoteFile(it) + } + } + } + } + + private fun handleRemoteFile(data: Intent) { + val file = FolderPickerActivity.EXTRA_FILES?.let { data.getParcelableArgument(it, OCFile::class.java) } + + Thread { + val user = currentAccountProvider?.user + val operation = RichDocumentsCreateAssetOperation(file?.remotePath) + val result = operation.execute(user, this) + if (result.isSuccess) { + val asset = result.singleData as String + runOnUiThread { + webView.evaluateJavascript( + "OCA.RichDocuments.documentsMain.postAsset('" + + file?.fileName + "', '" + asset + "');", + null + ) + } + } else { + runOnUiThread { DisplayUtils.showSnackMessage(this, "Inserting image failed!") } + } + }.start() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putString(EXTRA_URL, url) + super.onSaveInstanceState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + url = savedInstanceState.getString(EXTRA_URL) + super.onRestoreInstanceState(savedInstanceState) + } + + override fun onResume() { + super.onResume() + webView.evaluateJavascript( + "if (typeof OCA.RichDocuments.documentsMain.postGrabFocus !== 'undefined') " + + "{ OCA.RichDocuments.documentsMain.postGrabFocus(); }", + null + ) + } + + private fun printFile(url: Uri) { + val account = accountManager.currentOwnCloudAccount + if (account == null) { + DisplayUtils.showSnackMessage(webView, getString(R.string.failed_to_print)) + return + } + val targetFile = File(FileStorageUtils.getTemporalPath(account.name) + "/print.pdf") + PrintAsyncTask(targetFile, url.toString(), WeakReference(this)).execute() + } + + public override fun loadUrl(url: String?) { + if (TextUtils.isEmpty(url)) { + RichDocumentsLoadUrlTask(this, user.get(), file).execute() + } else { + super.loadUrl(url) + } + } + + private fun showSlideShow(url: Uri) { + val intent = Intent(this, ExternalSiteWebView::class.java) + intent.putExtra(EXTRA_URL, url.toString()) + intent.putExtra(EXTRA_SHOW_SIDEBAR, false) + intent.putExtra(EXTRA_SHOW_TOOLBAR, false) + startActivity(intent) + } + + private inner class RichDocumentsMobileInterface : MobileInterface() { + @JavascriptInterface + fun insertGraphic() { + openFileChooser() + } + + @JavascriptInterface + fun documentLoaded() { + runOnUiThread { hideLoading() } + } + + @JavascriptInterface + fun downloadAs(json: String?) { + try { + json ?: return + val downloadJson = JSONObject(json) + val url = downloadJson.getString(URL).toUri() + when (downloadJson.getString(TYPE)) { + PRINT -> printFile(url) + + SLIDESHOW -> showSlideShow(url) + + else -> { + val downloadFileName = downloadJson.optString(FILENAME, fileName) + downloadFile(url, downloadFileName) + } + } + } catch (e: JSONException) { + Log_OC.e(this, "Failed to parse download json message: $e") + } + } + + @JavascriptInterface + fun fileRename(renameString: String?) { + // when shared file is renamed in another instance, we will get notified about it + // need to change filename for sharing + try { + renameString ?: return + val renameJson = JSONObject(renameString) + val newName = renameJson.getString(NEW_NAME) + file?.fileName = newName + } catch (e: JSONException) { + Log_OC.e(this, "Failed to parse rename json message: $e") + } + } + + @JavascriptInterface + fun paste() { + // Javascript cannot do this by itself, so help out. + webView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_PASTE)) + webView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_PASTE)) + } + + @JavascriptInterface + fun hyperlink(hyperlink: String?) { + try { + hyperlink ?: return + val url = JSONObject(hyperlink).getString(HYPERLINK) + val intent = Intent(Intent.ACTION_VIEW) + intent.data = url.toUri() + startActivity(intent) + } catch (e: JSONException) { + Log_OC.e(this, "Failed to parse download json message: $e") + } + } + } + + companion object { + private const val URL = "URL" + private const val HYPERLINK = "Url" + private const val TYPE = "Type" + private const val PRINT = "print" + private const val SLIDESHOW = "slideshow" + private const val NEW_NAME = "NewName" + private const val FILENAME = "filename" + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java new file mode 100644 index 000000000000..5018c9f45e89 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java @@ -0,0 +1,1227 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2022-2023 Álvaro Brey + * SPDX-FileCopyrightText: 2017-2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2015-2017 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2014 Jose Antonio Barros Ramos + * SPDX-FileCopyrightText: 2013 María Asensio Valverde + * SPDX-FileCopyrightText: 2011-2015 Bartosz Przybylski + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Configuration; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.preference.Preference; +import android.preference.PreferenceActivity; +import android.preference.PreferenceCategory; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.URLUtil; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.etm.EtmActivity; +import com.nextcloud.client.logger.ui.LogsActivity; +import com.nextcloud.client.network.ClientFactory; +import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.client.preferences.AppPreferencesImpl; +import com.nextcloud.client.preferences.DarkMode; +import com.nextcloud.utils.extensions.ContextExtensionsKt; +import com.nextcloud.utils.mdm.MDMConfig; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.authentication.AuthenticatorActivity; +import com.owncloud.android.datamodel.ArbitraryDataProvider; +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; +import com.owncloud.android.datamodel.ExternalLinksProvider; +import com.owncloud.android.lib.common.ExternalLink; +import com.owncloud.android.lib.common.ExternalLinkType; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.providers.DocumentsStorageProvider; +import com.owncloud.android.ui.ThemeableSwitchPreference; +import com.owncloud.android.ui.asynctasks.LoadingVersionNumberTask; +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment; +import com.owncloud.android.ui.helpers.FileOperationsHelper; +import com.owncloud.android.ui.model.ExtendedSettingsActivityDialog; +import com.owncloud.android.utils.ClipboardUtil; +import com.owncloud.android.utils.DeviceCredentialUtils; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.PermissionUtil; +import com.owncloud.android.utils.theme.CapabilityUtils; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.util.List; +import java.util.Objects; + +import javax.inject.Inject; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.content.ContextCompat; +import androidx.core.content.res.ResourcesCompat; +import kotlin.Unit; +import kotlin.jvm.functions.Function1; + +import static com.owncloud.android.ui.activity.DrawerActivity.REQ_ALL_FILES_ACCESS; + +/** + * An Activity that allows the user to change the application's settings. + * It proxies the necessary calls via {@link androidx.appcompat.app.AppCompatDelegate} to be used with AppCompat. + */ +public class SettingsActivity extends PreferenceActivity + implements StorageMigration.StorageMigrationProgressListener, + LoadingVersionNumberTask.VersionDevInterface, + Injectable { + + private static final String TAG = SettingsActivity.class.getSimpleName(); + + public static final String PREFERENCE_LOCK = "lock"; + + public static final String LOCK_NONE = "none"; + public static final String LOCK_PASSCODE = "passcode"; + public static final String LOCK_DEVICE_CREDENTIALS = "device_credentials"; + + + public static final String PREFERENCE_SHOW_MEDIA_SCAN_NOTIFICATIONS = "show_media_scan_notifications"; + + private static final int ACTION_REQUEST_PASSCODE = 5; + private static final int ACTION_CONFIRM_PASSCODE = 6; + private static final int ACTION_CONFIRM_DEVICE_CREDENTIALS = 7; + private static final int ACTION_REQUEST_CODE_DAVDROID_SETUP = 10; + private static final int ACTION_SHOW_MNEMONIC = 11; + private static final int ACTION_E2E = 12; + private static final int TRUE_VALUE = 1; + + private static final String DAV_PATH = "/remote.php/dav"; + + public static final String SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI = "SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI"; + + private Uri serverBaseUri; + + private Preference lock; + private ThemeableSwitchPreference showHiddenFiles; + private ThemeableSwitchPreference showEcosystemApps; + private AppCompatDelegate delegate; + + private Preference prefDataLoc; + private String storagePath; + private String pendingLock; + + private User user; + @Inject ArbitraryDataProvider arbitraryDataProvider; + @Inject AppPreferences preferences; + @Inject UserAccountManager accountManager; + @Inject ClientFactory clientFactory; + @Inject ViewThemeUtils viewThemeUtils; + @Inject ConnectivityService connectivityService; + + @SuppressWarnings("deprecation") + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getDelegate().installViewFactory(); + getDelegate().onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.preferences); + getListView().setFitsSystemWindows(true); + setupActionBar(); + + // Register context menu for list of preferences. + registerForContextMenu(getListView()); + + String appVersion = getAppVersion(); + PreferenceScreen preferenceScreen = (PreferenceScreen) findPreference("preference_screen"); + + user = accountManager.getUser(); + + // retrieve user's base uri + setupBaseUri(); + + // General + setupGeneralCategory(); + + // Synced folders + setupAutoUploadCategory(preferenceScreen); + + // Files + setupFilesCategory(); + + // Details + setupDetailsCategory(preferenceScreen); + + // Sync + setupSyncCategory(); + + // More + setupMoreCategory(); + + // About + setupAboutCategory(appVersion); + + // Dev + setupDevCategory(preferenceScreen); + + // workaround for mismatched color when app dark mode and system dark mode don't agree + setListBackground(); + showPasscodeDialogIfEnforceAppProtection(); + } + + public static boolean isBackPressed = false; + + @SuppressLint("GestureBackNavigation") + @Override + public void onBackPressed() { + isBackPressed = true; + super.onBackPressed(); + new Handler(Looper.getMainLooper()).postDelayed(() -> { + Log_OC.d(TAG, "User returned from settings activity, reset onBackPressed flag."); + isBackPressed = false; + }, 2000); + } + + private void showPasscodeDialogIfEnforceAppProtection() { + if (MDMConfig.INSTANCE.enforceProtection(this) && Objects.equals(preferences.getLockPreference(), SettingsActivity.LOCK_NONE) && lock != null) { + Intent intent = ExtendedSettingsActivity.Companion.createIntent(this, ExtendedSettingsActivityDialog.AppPasscode, false); + startActivityForResult(intent, ExtendedSettingsActivityDialog.AppPasscode.getResultId()); + } + } + + private void setupDevCategory(PreferenceScreen preferenceScreen) { + // Dev category + PreferenceCategory preferenceCategoryDev = (PreferenceCategory) findPreference("dev_category"); + + if (getResources().getBoolean(R.bool.is_beta)) { + viewThemeUtils.files.themePreferenceCategory(preferenceCategoryDev); + + /* Link to dev apks */ + Preference pDevLink = findPreference("dev_link"); + if (pDevLink != null) { + if (getResources().getBoolean(R.bool.dev_version_direct_download_enabled)) { + pDevLink.setOnPreferenceClickListener(preference -> { + FileActivity.checkForNewDevVersion(this, getApplicationContext()); + return true; + }); + } else { + preferenceCategoryDev.removePreference(pDevLink); + } + } + + /* Link to dev changelog */ + Preference pChangelogLink = findPreference("changelog_link"); + if (pChangelogLink != null) { + pChangelogLink.setOnPreferenceClickListener(preference -> { + DisplayUtils.startLinkIntent(this, R.string.dev_changelog); + return true; + }); + } + + /* Engineering Test Mode */ + Preference pEtm = findPreference("etm"); + if (pEtm != null) { + pEtm.setOnPreferenceClickListener(preference -> { + EtmActivity.launch(this); + return true; + }); + } + } else { + preferenceScreen.removePreference(preferenceCategoryDev); + } + } + + private void setupAboutCategory(String appVersion) { + final PreferenceCategory preferenceCategoryAbout = (PreferenceCategory) findPreference("about"); + viewThemeUtils.files.themePreferenceCategory(preferenceCategoryAbout); + + /* About App */ + Preference pAboutApp = findPreference("about_app"); + if (pAboutApp != null) { + pAboutApp.setTitle(String.format(getString(R.string.about_android), getString(R.string.app_name))); + + String buildNumber = getResources().getString(R.string.buildNumber); + + if (TextUtils.isEmpty(buildNumber)) { + pAboutApp.setSummary(String.format(getString(R.string.about_version), appVersion)); + } else { + pAboutApp.setSummary(String.format(getString(R.string.about_version_with_build), + appVersion, + buildNumber)); + } + } + + // license + boolean licenseEnabled = getResources().getBoolean(R.bool.license_enabled); + Preference licensePreference = findPreference("license"); + if (licensePreference != null) { + if (licenseEnabled) { + licensePreference.setSummary(R.string.prefs_gpl_v2); + licensePreference.setOnPreferenceClickListener(preference -> { + DisplayUtils.startLinkIntent(this, R.string.license_url); + return true; + }); + } else { + preferenceCategoryAbout.removePreference(licensePreference); + } + } + + // privacy + boolean privacyEnabled = getResources().getBoolean(R.bool.privacy_enabled); + Preference privacyPreference = findPreference("privacy"); + if (privacyPreference != null) { + if (privacyEnabled && URLUtil.isValidUrl(getString(R.string.privacy_url))) { + privacyPreference.setOnPreferenceClickListener(preference -> { + try { + Uri privacyUrl = Uri.parse(getString(R.string.privacy_url)); + String mimeType = MimeTypeUtil.getBestMimeTypeByFilename(privacyUrl.getLastPathSegment()); + + Intent intent; + if (MimeTypeUtil.isPDF(mimeType)) { + intent = new Intent(Intent.ACTION_VIEW, privacyUrl); + DisplayUtils.startIntentIfAppAvailable(intent, this, R.string.no_pdf_app_available); + } else { + intent = new Intent(getApplicationContext(), ExternalSiteWebView.class); + intent.putExtra(ExternalSiteWebView.EXTRA_TITLE, + getResources().getString(R.string.privacy)); + intent.putExtra(ExternalSiteWebView.EXTRA_URL, privacyUrl.toString()); + intent.putExtra(ExternalSiteWebView.EXTRA_SHOW_SIDEBAR, false); + } + + startActivity(intent); + } catch (Exception e) { + Log_OC.e(TAG, "Could not parse privacy url"); + preferenceCategoryAbout.removePreference(privacyPreference); + } + return true; + }); + } else { + preferenceCategoryAbout.removePreference(privacyPreference); + } + } + + // source code + boolean sourcecodeEnabled = getResources().getBoolean(R.bool.sourcecode_enabled); + Preference sourcecodePreference = findPreference("sourcecode"); + if (sourcecodePreference != null) { + if (sourcecodeEnabled) { + sourcecodePreference.setOnPreferenceClickListener(preference -> { + DisplayUtils.startLinkIntent(this, R.string.sourcecode_url); + return true; + }); + } else { + preferenceCategoryAbout.removePreference(sourcecodePreference); + } + } + } + + private void setupSyncCategory() { + final PreferenceCategory preferenceCategorySync = (PreferenceCategory) findPreference("sync"); + viewThemeUtils.files.themePreferenceCategory(preferenceCategorySync); + + setupAutoUploadPreference(preferenceCategorySync); + setupInternalTwoWaySyncPreference(); + setupAllFilesAccessPreference(preferenceCategorySync); + } + + private void setupMoreCategory() { + final PreferenceCategory preferenceCategoryMore = (PreferenceCategory) findPreference("more"); + viewThemeUtils.files.themePreferenceCategory(preferenceCategoryMore); + + setupCalendarPreference(preferenceCategoryMore); + + setupBackupPreference(); + + setupE2EPreference(preferenceCategoryMore); + + setupE2EKeysExist(preferenceCategoryMore); + + setupE2EMnemonicPreference(preferenceCategoryMore); + + removeE2E(preferenceCategoryMore); + + setupHelpPreference(preferenceCategoryMore); + + setupRecommendPreference(preferenceCategoryMore); + + setupLoggingPreference(preferenceCategoryMore); + + setupImprintPreference(preferenceCategoryMore); + + loadExternalSettingLinks(preferenceCategoryMore); + } + + private void setupImprintPreference(PreferenceCategory preferenceCategoryMore) { + boolean imprintEnabled = getResources().getBoolean(R.bool.imprint_enabled); + Preference pImprint = findPreference("imprint"); + if (pImprint != null) { + if (imprintEnabled) { + pImprint.setOnPreferenceClickListener(preference -> { + String imprintWeb = getString(R.string.url_imprint); + + if (!imprintWeb.isEmpty()) { + DisplayUtils.startLinkIntent(this, imprintWeb); + } + //ImprintDialog.newInstance(true).show(preference.get, "IMPRINT_DIALOG"); + return true; + }); + } else { + preferenceCategoryMore.removePreference(pImprint); + } + } + } + + private void setupLoggingPreference(PreferenceCategory preferenceCategoryMore) { + Preference pLogger = findPreference("logger"); + if (pLogger != null) { + if (MDMConfig.INSTANCE.isLogEnabled(this)) { + pLogger.setOnPreferenceClickListener(preference -> { + Intent loggerIntent = new Intent(getApplicationContext(), LogsActivity.class); + startActivity(loggerIntent); + + return true; + }); + } else { + preferenceCategoryMore.removePreference(pLogger); + } + } + } + + + private void setupRecommendPreference(PreferenceCategory preferenceCategoryMore) { + boolean recommendEnabled = getResources().getBoolean(R.bool.recommend_enabled); + Preference pRecommend = findPreference("recommend"); + if (pRecommend != null) { + if (recommendEnabled) { + pRecommend.setOnPreferenceClickListener(preference -> { + + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + String appName = getString(R.string.app_name); + String downloadUrlGooglePlayStore = getString(R.string.url_app_download); + String downloadUrlFDroid = getString(R.string.fdroid_link); + String downloadUrls = String.format(getString(R.string.recommend_urls), + downloadUrlGooglePlayStore, downloadUrlFDroid); + + String recommendSubject = String.format(getString(R.string.recommend_subject), appName); + String recommendText = String.format(getString(R.string.recommend_text), + appName, downloadUrls); + + intent.putExtra(Intent.EXTRA_SUBJECT, recommendSubject); + intent.putExtra(Intent.EXTRA_TEXT, recommendText); + startActivity(intent); + + return true; + + }); + } else { + preferenceCategoryMore.removePreference(pRecommend); + } + } + } + + private void setupE2EPreference(PreferenceCategory preferenceCategoryMore) { + Preference preference = findPreference("setup_e2e"); + + if (preference != null) { + if (FileOperationsHelper.isEndToEndEncryptionSetup(this, user) || + CapabilityUtils.getCapability(this).getEndToEndEncryptionKeysExist().isTrue() || + CapabilityUtils.getCapability(this).getEndToEndEncryptionKeysExist().isUnknown() + ) { + preferenceCategoryMore.removePreference(preference); + } else { + preference.setOnPreferenceClickListener(p -> { + if (connectivityService.getConnectivity().isConnected()) { + Intent i = new Intent(MainApp.getAppContext(), SetupEncryptionActivity.class); + i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + i.putExtra("EXTRA_USER", user); + startActivityForResult(i, ACTION_E2E); + } else { + DisplayUtils.showSnackMessage(this, R.string.e2e_offline); + } + + return true; + }); + } + } + } + + private void setupE2EKeysExist(PreferenceCategory preferenceCategoryMore) { + Preference preference = findPreference("setup_e2e_keys_exist"); + + if (preference != null) { + if (!CapabilityUtils.getCapability(this).getEndToEndEncryptionKeysExist().isTrue() || + (CapabilityUtils.getCapability(this).getEndToEndEncryptionKeysExist().isTrue() && + FileOperationsHelper.isEndToEndEncryptionSetup(this, user))) { + preferenceCategoryMore.removePreference(preference); + } else { + preference.setOnPreferenceClickListener(p -> { + Intent i = new Intent(MainApp.getAppContext(), SetupEncryptionActivity.class); + i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + i.putExtra("EXTRA_USER", user); + startActivityForResult(i, ACTION_E2E); + + return true; + }); + } + } + } + + private void setupE2EMnemonicPreference(PreferenceCategory preferenceCategoryMore) { + String mnemonic = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.MNEMONIC).trim(); + + Preference pMnemonic = findPreference("mnemonic"); + if (pMnemonic != null) { + if (!mnemonic.isEmpty()) { + if (DeviceCredentialUtils.areCredentialsAvailable(this)) { + pMnemonic.setOnPreferenceClickListener(preference -> { + + Intent i = new Intent(MainApp.getAppContext(), RequestCredentialsActivity.class); + i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + startActivityForResult(i, ACTION_SHOW_MNEMONIC); + + return true; + }); + } else { + pMnemonic.setEnabled(false); + pMnemonic.setSummary(R.string.prefs_e2e_no_device_credentials); + } + } else { + preferenceCategoryMore.removePreference(pMnemonic); + } + } + } + + private void removeE2E(PreferenceCategory preferenceCategoryMore) { + Preference preference = findPreference("remove_e2e"); + + if (preference != null) { + if (!FileOperationsHelper.isEndToEndEncryptionSetup(this, user)) { + preferenceCategoryMore.removePreference(preference); + } else { + preference.setOnPreferenceClickListener(p -> { + showRemoveE2EAlertDialog(preferenceCategoryMore, preference); + return true; + }); + } + } + } + + private void showRemoveE2EAlertDialog(PreferenceCategory preferenceCategoryMore, Preference preference) { + new MaterialAlertDialogBuilder(this, R.style.FallbackTheming_Dialog) + .setTitle(R.string.prefs_e2e_mnemonic) + .setMessage(getString(R.string.remove_e2e_message)) + .setCancelable(true) + .setNegativeButton(R.string.common_cancel, ((dialog, i) -> dialog.dismiss())) + .setPositiveButton(R.string.confirm_removal, (dialog, which) -> { + EncryptionUtils.removeE2E(arbitraryDataProvider, user); + preferenceCategoryMore.removePreference(preference); + + Preference pMnemonic = findPreference("mnemonic"); + if (pMnemonic != null) { + preferenceCategoryMore.removePreference(pMnemonic); + } + + dialog.dismiss(); + }) + .create() + .show(); + } + + private void setupHelpPreference(PreferenceCategory preferenceCategoryMore) { + boolean helpEnabled = getResources().getBoolean(R.bool.help_enabled); + Preference pHelp = findPreference("help"); + if (pHelp != null) { + if (helpEnabled) { + pHelp.setOnPreferenceClickListener(preference -> { + DisplayUtils.startLinkIntent(this, R.string.url_help); + return true; + }); + } else { + preferenceCategoryMore.removePreference(pHelp); + } + } + } + + private void setupAutoUploadPreference(PreferenceCategory preferenceCategoryMore) { + Preference autoUpload = findPreference("syncedFolders"); + if (getResources().getBoolean(R.bool.syncedFolder_light)) { + preferenceCategoryMore.removePreference(autoUpload); + } else { + autoUpload.setOnPreferenceClickListener(preference -> { + Intent intent = new Intent(this, SyncedFoldersActivity.class); + startActivity(intent); + return true; + }); + } + } + + private void setupInternalTwoWaySyncPreference() { + Preference twoWaySync = findPreference("internal_two_way_sync"); + + twoWaySync.setOnPreferenceClickListener(preference -> { + Intent intent = new Intent(this, InternalTwoWaySyncActivity.class); + startActivity(intent); + return true; + }); + } + + private void setupAllFilesAccessPreference(PreferenceCategory category) { + Preference allFilesAccess = findPreference("allFilesAccess"); + + if (PermissionUtil.checkAllFilesAccess()) { + category.removePreference(allFilesAccess); + } else { + if (allFilesAccess.getParent() == null) { + category.addPreference(allFilesAccess); + } + } + + allFilesAccess.setOnPreferenceClickListener(preference -> { + ContextExtensionsKt.openAllFilesAccessSettings(this, REQ_ALL_FILES_ACCESS); + return true; + }); + } + + private void setupBackupPreference() { + Preference pContactsBackup = findPreference("backup"); + if (pContactsBackup != null) { + boolean showCalendarBackup = getResources().getBoolean(R.bool.show_calendar_backup); + pContactsBackup.setTitle(showCalendarBackup + ? getString(R.string.backup_title) + : getString(R.string.contact_backup_title)); + pContactsBackup.setSummary(showCalendarBackup + ? getString(R.string.prefs_daily_backup_summary) + : getString(R.string.prefs_daily_contact_backup_summary)); + pContactsBackup.setOnPreferenceClickListener(preference -> { + ContactsPreferenceActivity.startActivityWithoutSidebar(this); + return true; + }); + } + } + + private void setupCalendarPreference(PreferenceCategory preferenceCategoryMore) { + boolean calendarContactsEnabled = getResources().getBoolean(R.bool.davdroid_integration_enabled); + Preference pCalendarContacts = findPreference("calendar_contacts"); + if (pCalendarContacts != null) { + if (calendarContactsEnabled) { + final Activity activity = this; + pCalendarContacts.setOnPreferenceClickListener(preference -> { + try { + launchDavDroidLogin(); + } catch (Throwable t) { + Log_OC.e(TAG, "Error while setting up DavX5", t); + DisplayUtils.showSnackMessage( + activity, + R.string.prefs_davx5_setup_error); + } + return true; + }); + } else { + preferenceCategoryMore.removePreference(pCalendarContacts); + } + } + } + + private void setupDetailsCategory(PreferenceScreen preferenceScreen) { + PreferenceCategory preferenceCategoryDetails = (PreferenceCategory) findPreference("details"); + viewThemeUtils.files.themePreferenceCategory(preferenceCategoryDetails); + + boolean fPassCodeEnabled = getResources().getBoolean(R.bool.passcode_enabled); + boolean fDeviceCredentialsEnabled = getResources().getBoolean(R.bool.device_credentials_enabled); + boolean fShowEcosystemAppsEnabled = !getResources().getBoolean(R.bool.is_branded_client); + boolean fSyncedFolderLightEnabled = getResources().getBoolean(R.bool.syncedFolder_light); + boolean fShowMediaScanNotifications = preferences.isShowMediaScanNotifications(); + + setupLockPreference(preferenceCategoryDetails, fPassCodeEnabled, fDeviceCredentialsEnabled); + + setupShowEcosystemAppsPreference(preferenceCategoryDetails, fShowEcosystemAppsEnabled); + + setupShowMediaScanNotifications(preferenceCategoryDetails, fShowMediaScanNotifications); + + if (!fPassCodeEnabled && !fDeviceCredentialsEnabled && fSyncedFolderLightEnabled + && fShowMediaScanNotifications) { + preferenceScreen.removePreference(preferenceCategoryDetails); + } + } + + private void setupFilesCategory() { + PreferenceCategory preferenceCategoryDetails = (PreferenceCategory) findPreference("files"); + viewThemeUtils.files.themePreferenceCategory(preferenceCategoryDetails); + + boolean fShowHiddenFilesEnabled = getResources().getBoolean(R.bool.show_hidden_files_enabled); + + setupHiddenFilesPreference(preferenceCategoryDetails, fShowHiddenFilesEnabled); + setupFoldersBeforeFilesPreference(); + setupSortFavoritesFirstPreference(); + } + + private void setupShowMediaScanNotifications(PreferenceCategory preferenceCategoryDetails, + boolean fShowMediaScanNotifications) { + ThemeableSwitchPreference mShowMediaScanNotifications = + (ThemeableSwitchPreference) findPreference(PREFERENCE_SHOW_MEDIA_SCAN_NOTIFICATIONS); + + if (fShowMediaScanNotifications) { + preferenceCategoryDetails.removePreference(mShowMediaScanNotifications); + } + } + + private void setupHiddenFilesPreference(PreferenceCategory preferenceCategoryDetails, + boolean fShowHiddenFilesEnabled) { + showHiddenFiles = (ThemeableSwitchPreference) findPreference("show_hidden_files"); + if (fShowHiddenFilesEnabled) { + showHiddenFiles.setOnPreferenceClickListener(preference -> { + preferences.setShowHiddenFilesEnabled(showHiddenFiles.isChecked()); + return true; + }); + } else { + preferenceCategoryDetails.removePreference(showHiddenFiles); + } + } + + private void setupFoldersBeforeFilesPreference() { + ThemeableSwitchPreference preference = (ThemeableSwitchPreference) findPreference("sort_folders_before_files"); + preference.setOnPreferenceClickListener(p -> { + preferences.setSortFoldersBeforeFiles(preference.isChecked()); + return true; + }); + } + + private void setupSortFavoritesFirstPreference() { + ThemeableSwitchPreference preference = (ThemeableSwitchPreference) findPreference("sort_favorites_first"); + preference.setOnPreferenceClickListener(p -> { + preferences.setSortFavoritesFirst(preference.isChecked()); + return true; + }); + } + + private void setupShowEcosystemAppsPreference(PreferenceCategory preferenceCategoryDetails, boolean fShowEcosystemAppsEnabled) { + showEcosystemApps = (ThemeableSwitchPreference) findPreference("show_ecosystem_apps"); + if (fShowEcosystemAppsEnabled) { + showEcosystemApps.setOnPreferenceClickListener(preference -> { + preferences.setShowEcosystemApps(showEcosystemApps.isChecked()); + return true; + }); + } else { + preferenceCategoryDetails.removePreference(showEcosystemApps); + } + } + + + private void setupLockPreference(PreferenceCategory preferenceCategoryDetails, + boolean passCodeEnabled, + boolean deviceCredentialsEnabled) { + lock = findPreference(PREFERENCE_LOCK); + if (lock != null && (passCodeEnabled || deviceCredentialsEnabled)) { + String currentLock = preferences.getLockPreference(); + updateLockSummary(lock, currentLock); + + lock.setOnPreferenceClickListener(preference -> { + Intent intent = ExtendedSettingsActivity.Companion.createIntent(this, ExtendedSettingsActivityDialog.AppPasscode); + startActivityForResult(intent, ExtendedSettingsActivityDialog.AppPasscode.getResultId()); + return true; + }); + } else { + preferenceCategoryDetails.removePreference(lock); + } + } + + private void updateLockSummary(Preference lockPreference, String lockValue) { + String summary; + if (LOCK_PASSCODE.equals(lockValue)) { + summary = getString(R.string.prefs_lock_using_passcode); + } else if (LOCK_DEVICE_CREDENTIALS.equals(lockValue)) { + summary = getString(R.string.prefs_lock_using_device_credentials); + } else { + summary = getString(R.string.prefs_lock_none); + } + lockPreference.setSummary(summary); + } + + private void setupAutoUploadCategory(PreferenceScreen preferenceScreen) { + final PreferenceCategory preferenceCategorySyncedFolders = + (PreferenceCategory) findPreference("synced_folders_category"); + viewThemeUtils.files.themePreferenceCategory(preferenceCategorySyncedFolders); + + if (!getResources().getBoolean(R.bool.syncedFolder_light)) { + preferenceScreen.removePreference(preferenceCategorySyncedFolders); + } else { + // Upload on WiFi + final ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(this); + + final SwitchPreference pUploadOnWifiCheckbox = (SwitchPreference) findPreference("synced_folder_on_wifi"); + pUploadOnWifiCheckbox.setChecked( + arbitraryDataProvider.getBooleanValue(user, SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI)); + + pUploadOnWifiCheckbox.setOnPreferenceClickListener(preference -> { + arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI, + String.valueOf(pUploadOnWifiCheckbox.isChecked())); + + return true; + }); + + Preference pSyncedFolder = findPreference("synced_folders_configure_folders"); + if (pSyncedFolder != null) { + if (getResources().getBoolean(R.bool.syncedFolder_light)) { + pSyncedFolder.setOnPreferenceClickListener(preference -> { + Intent intent = new Intent(this, SyncedFoldersActivity.class); + startActivity(intent); + return true; + }); + } else { + preferenceCategorySyncedFolders.removePreference(pSyncedFolder); + } + } + } + } + + private void enableLock(String lock) { + pendingLock = LOCK_NONE; + if (LOCK_PASSCODE.equals(lock)) { + Intent i = new Intent(getApplicationContext(), PassCodeActivity.class); + i.setAction(PassCodeActivity.ACTION_REQUEST_WITH_RESULT); + startActivityForResult(i, ACTION_REQUEST_PASSCODE); + } else if (LOCK_DEVICE_CREDENTIALS.equals(lock)) { + if (!DeviceCredentialUtils.areCredentialsAvailable(getApplicationContext())) { + DisplayUtils.showSnackMessage(this, R.string.prefs_lock_device_credentials_not_setup); + } else { + DisplayUtils.showSnackMessage(this, R.string.prefs_lock_device_credentials_enabled); + changeLockSetting(LOCK_DEVICE_CREDENTIALS); + } + } + } + + private void changeLockSetting(String value) { + preferences.setLockPreference(value); + if (lock != null) { + updateLockSummary(lock, value); + } + DocumentsStorageProvider.notifyRootsChanged(this); + } + + private void disableLock(String lock) { + if (LOCK_PASSCODE.equals(lock)) { + Intent i = new Intent(getApplicationContext(), PassCodeActivity.class); + i.setAction(PassCodeActivity.ACTION_CHECK_WITH_RESULT); + startActivityForResult(i, ACTION_CONFIRM_PASSCODE); + } else if (LOCK_DEVICE_CREDENTIALS.equals(lock)) { + Intent i = new Intent(getApplicationContext(), RequestCredentialsActivity.class); + startActivityForResult(i, ACTION_CONFIRM_DEVICE_CREDENTIALS); + } + } + + private void setupGeneralCategory() { + final PreferenceCategory preferenceCategoryGeneral = (PreferenceCategory) findPreference("general"); + viewThemeUtils.files.themePreferenceCategory(preferenceCategoryGeneral); + + readStoragePath(); + + prefDataLoc = findPreference(AppPreferencesImpl.DATA_STORAGE_LOCATION); + if (prefDataLoc != null) { + prefDataLoc.setOnPreferenceClickListener(p -> { + Intent intent = ExtendedSettingsActivity.Companion.createIntent(this, ExtendedSettingsActivityDialog.StorageLocation); + startActivityForResult(intent, ExtendedSettingsActivityDialog.StorageLocation.getResultId()); + return true; + }); + } + + final var themePref = findPreference("darkMode"); + if (themePref != null) { + updateThemePreferenceSummary(preferences.getDarkThemeMode().name()); + + themePref.setOnPreferenceClickListener(preference -> { + Intent intent = ExtendedSettingsActivity.Companion.createIntent(this, ExtendedSettingsActivityDialog.ThemeSelection); + startActivityForResult(intent, ExtendedSettingsActivityDialog.ThemeSelection.getResultId()); + return true; + }); + } + } + + private void updateThemePreferenceSummary(String themeValue) { + Preference themePref = findPreference("darkMode"); + if (themePref == null) return; + + DarkMode mode; + try { + mode = DarkMode.valueOf(themeValue); + } catch (IllegalArgumentException e) { + mode = DarkMode.SYSTEM; + } + + String summary = switch (mode) { + case LIGHT -> getString(R.string.prefs_value_theme_light); + case DARK -> getString(R.string.prefs_value_theme_dark); + default -> getString(R.string.prefs_value_theme_system); + }; + + themePref.setSummary(summary); + } + + private void setListBackground() { + getListView().setBackgroundColor(ContextCompat.getColor(this, R.color.bg_default)); + } + + private String getAppVersion() { + String temp; + try { + PackageInfo pkg = getPackageManager().getPackageInfo(getPackageName(), 0); + temp = pkg.versionName; + } catch (NameNotFoundException e) { + temp = ""; + Log_OC.e(TAG, "Error while showing about dialog", e); + } + return temp; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + finish(); + return super.onOptionsItemSelected(item); + } + + private void setupActionBar() { + ActionBar actionBar = getDelegate().getSupportActionBar(); + if (actionBar == null) return; + + viewThemeUtils.platform.themeStatusBar(this); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowTitleEnabled(true); + + if (getResources() == null) return; + Drawable menuIcon = ResourcesCompat.getDrawable(getResources(), + R.drawable.ic_arrow_back, + null); + + if (menuIcon == null) return; + viewThemeUtils.androidx.themeActionBar(this, + actionBar, + getString(R.string.actionbar_settings), + menuIcon); + } + + private void launchDavDroidLogin() { + Intent davDroidLoginIntent = new Intent(); + davDroidLoginIntent.setClassName("at.bitfire.davdroid", "at.bitfire.davdroid.ui.setup.LoginActivity"); + if (getPackageManager().resolveActivity(davDroidLoginIntent, 0) != null) { + // arguments + if (serverBaseUri != null) { + davDroidLoginIntent.putExtra("url", serverBaseUri + DAV_PATH); + + davDroidLoginIntent.putExtra("loginFlow", TRUE_VALUE); + davDroidLoginIntent.setData(Uri.parse(serverBaseUri.toString() + AuthenticatorActivity.WEB_LOGIN)); + davDroidLoginIntent.putExtra("davPath", DAV_PATH); + } + davDroidLoginIntent.putExtra("username", UserAccountManager.getUsername(user)); + + startActivityForResult(davDroidLoginIntent, ACTION_REQUEST_CODE_DAVDROID_SETUP); + } else { + // DAVdroid not installed + Intent installIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=at.bitfire.davdroid")); + + // launch market(s) + if (installIntent.resolveActivity(getPackageManager()) != null) { + startActivity(installIntent); + } else { + // no f-droid market app or Play store installed --> launch browser for f-droid url + DisplayUtils.startLinkIntent(this, "https://f-droid.org/packages/at.bitfire.davdroid/"); + + DisplayUtils.showSnackMessage(this, R.string.prefs_calendar_contacts_no_store_error); + } + } + } + + private void setupBaseUri() { + // retrieve and set user's base URI + Thread t = new Thread(() -> { + try { + serverBaseUri = clientFactory.create(user).getBaseUri(); + } catch (Exception e) { + Log_OC.e(TAG, "Error retrieving user's base URI", e); + } + }); + t.start(); + } + + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + return true; + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == ACTION_REQUEST_PASSCODE && resultCode == RESULT_CANCELED) { + showPasscodeDialogIfEnforceAppProtection(); + } else if (requestCode == ACTION_REQUEST_PASSCODE && resultCode == RESULT_OK) { + String passcode = data.getStringExtra(PassCodeActivity.KEY_PASSCODE); + if (passcode != null && passcode.length() == 4) { + SharedPreferences.Editor appPrefs = PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()).edit(); + + for (int i = 1; i <= 4; ++i) { + appPrefs.putString(PassCodeActivity.PREFERENCE_PASSCODE_D + i, passcode.substring(i - 1, i)); + } + appPrefs.apply(); + changeLockSetting(LOCK_PASSCODE); + DisplayUtils.showSnackMessage(this, R.string.pass_code_stored); + } + } else if (requestCode == ACTION_CONFIRM_PASSCODE && resultCode == RESULT_OK) { + if (data.getBooleanExtra(PassCodeActivity.KEY_CHECK_RESULT, false)) { + changeLockSetting(LOCK_NONE); + + DisplayUtils.showSnackMessage(this, R.string.pass_code_removed); + if (!LOCK_NONE.equals(pendingLock)) { + enableLock(pendingLock); + } + } + } else if (requestCode == ACTION_REQUEST_CODE_DAVDROID_SETUP && resultCode == RESULT_OK) { + DisplayUtils.showSnackMessage(this, R.string.prefs_calendar_contacts_sync_setup_successful); + } else if (requestCode == ACTION_CONFIRM_DEVICE_CREDENTIALS && resultCode == RESULT_OK && + data.getIntExtra(RequestCredentialsActivity.KEY_CHECK_RESULT, + RequestCredentialsActivity.KEY_CHECK_RESULT_FALSE) == + RequestCredentialsActivity.KEY_CHECK_RESULT_TRUE) { + changeLockSetting(LOCK_NONE); + DisplayUtils.showSnackMessage(this, R.string.credentials_disabled); + if (!LOCK_NONE.equals(pendingLock)) { + enableLock(pendingLock); + } + } else if (requestCode == ACTION_SHOW_MNEMONIC && resultCode == RESULT_OK) { + handleMnemonicRequest(data); + } else if (requestCode == ACTION_E2E && data != null && data.getBooleanExtra(SetupEncryptionDialogFragment.SUCCESS, false)) { + Intent i = new Intent(this, SettingsActivity.class); + startActivity(i); + } else if (requestCode == ExtendedSettingsActivityDialog.StorageLocation.getResultId() && data != null) { + String newPath = data.getStringExtra(ExtendedSettingsActivityDialog.StorageLocation.getKey()); + if (storagePath != null && !storagePath.equals(newPath)) { + StorageMigration storageMigration = new StorageMigration(this, user, storagePath, newPath, viewThemeUtils); + storageMigration.setStorageMigrationProgressListener(this); + storageMigration.migrate(); + } + } else if (requestCode == ExtendedSettingsActivityDialog.ThemeSelection.getResultId() && data != null) { + String selectedTheme = data.getStringExtra(ExtendedSettingsActivityDialog.ThemeSelection.getKey()); + if (selectedTheme != null) { + updateThemePreferenceSummary(selectedTheme); + + // needed for to change status bar color + recreate(); + } + } else if (requestCode == ExtendedSettingsActivityDialog.AppPasscode.getResultId() && data != null) { + String selectedLock = data.getStringExtra(ExtendedSettingsActivityDialog.AppPasscode.getKey()); + if (selectedLock != null) { + String currentLock = preferences.getLockPreference(); + if (!currentLock.equals(selectedLock)) { + if (LOCK_NONE.equals(currentLock)) { + enableLock(selectedLock); + } else { + pendingLock = selectedLock; + disableLock(currentLock); + } + } + } + } else if (requestCode == REQ_ALL_FILES_ACCESS) { + final PreferenceCategory preferenceCategorySync = (PreferenceCategory) findPreference("sync"); + setupAllFilesAccessPreference(preferenceCategorySync); + } + } + + @VisibleForTesting + public void handleMnemonicRequest(Intent data) { + if (data == null) { + DisplayUtils.showSnackMessage(this, "Error retrieving mnemonic!"); + } else { + if (data.getIntExtra(RequestCredentialsActivity.KEY_CHECK_RESULT, + RequestCredentialsActivity.KEY_CHECK_RESULT_FALSE) == + RequestCredentialsActivity.KEY_CHECK_RESULT_TRUE) { + + ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(this); + String mnemonic = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.MNEMONIC).trim(); + showMnemonicAlertDialogDialog(mnemonic); + } + } + } + + private void showMnemonicAlertDialogDialog(String mnemonic) { + new MaterialAlertDialogBuilder(this, R.style.FallbackTheming_Dialog) + .setTitle(R.string.prefs_e2e_mnemonic) + .setMessage(mnemonic) + .setPositiveButton(R.string.common_ok, (dialog, which) -> dialog.dismiss()) + .setNegativeButton(R.string.common_cancel, (dialog, i) -> dialog.dismiss()) + .setNeutralButton(R.string.common_copy, (dialog, i) -> + ClipboardUtil.copyToClipboard(this, mnemonic, false)) + .create() + .show(); + } + + @Override + @NonNull + public MenuInflater getMenuInflater() { + return getDelegate().getMenuInflater(); + } + + @Override + public void setContentView(@LayoutRes int layoutResID) { + getDelegate().setContentView(layoutResID); + } + + @Override + public void setContentView(View view) { + getDelegate().setContentView(view); + } + + @Override + public void setContentView(View view, ViewGroup.LayoutParams params) { + getDelegate().setContentView(view, params); + } + + @Override + public void addContentView(View view, ViewGroup.LayoutParams params) { + getDelegate().addContentView(view, params); + } + + @Override + protected void onPostResume() { + super.onPostResume(); + getDelegate().onPostResume(); + } + + @Override + protected void onTitleChanged(CharSequence title, int color) { + super.onTitleChanged(title, color); + getDelegate().setTitle(title); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + getDelegate().onConfigurationChanged(newConfig); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + getDelegate().onPostCreate(savedInstanceState); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + getDelegate().onDestroy(); + } + + @Override + protected void onStop() { + super.onStop(); + getDelegate().onStop(); + } + + public void invalidateOptionsMenu() { + getDelegate().invalidateOptionsMenu(); + } + + private AppCompatDelegate getDelegate() { + if (delegate == null) { + delegate = AppCompatDelegate.create(this, null); + } + return delegate; + } + + private void loadExternalSettingLinks(PreferenceCategory preferenceCategory) { + if (!MDMConfig.INSTANCE.externalSiteSupport(this)) { + return; + } + + ExternalLinksProvider externalLinksProvider = new ExternalLinksProvider(getContentResolver()); + externalLinksProvider.getExternalLink(ExternalLinkType.SETTINGS, externalLinks -> { + for (final ExternalLink link : externalLinks) { + // only add if it does not exist, in case activity is reused + if (findPreference(String.valueOf(link.getId())) == null) { + Preference p = new Preference(SettingsActivity.this); + p.setTitle(link.getName()); + p.setKey(String.valueOf(link.getId())); + + p.setOnPreferenceClickListener(preference -> { + Intent externalWebViewIntent = new Intent(getApplicationContext(), ExternalSiteWebView.class); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_TITLE, link.getName()); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_URL, link.getUrl()); + externalWebViewIntent.putExtra(ExternalSiteWebView.EXTRA_SHOW_SIDEBAR, false); + startActivity(externalWebViewIntent); + + return true; + }); + + preferenceCategory.addPreference(p); + } + } + return Unit.INSTANCE; + }); + externalLinksProvider.cleanup(); + } + + /** + * Save storage path + */ + private void saveStoragePath(String newStoragePath) { + SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + storagePath = newStoragePath; + MainApp.setStoragePath(storagePath); + SharedPreferences.Editor editor = appPrefs.edit(); + editor.putString(AppPreferencesImpl.STORAGE_PATH, storagePath); + editor.apply(); + } + + private void readStoragePath() { + SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + // Load storage path from shared preferences. Use private internal storage by default. + storagePath = appPrefs.getString(AppPreferencesImpl.STORAGE_PATH, getApplicationContext().getFilesDir().getAbsolutePath()); + } + + @Override + public void onStorageMigrationFinished(String storagePath, boolean succeed) { + if (succeed) { + saveStoragePath(storagePath); + } + } + + @Override + public void onCancelMigration() { + // Migration was canceled so we don't do anything + } + + @Override + public void returnVersion(Integer latestVersion) { + FileActivity.showDevSnackbar(this, latestVersion, true, false); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt new file mode 100644 index 000000000000..65f438fa5c56 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.nextcloud.client.account.User +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment +import com.owncloud.android.utils.DisplayUtils + +class SetupEncryptionActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val user = intent?.getParcelableArgument("EXTRA_USER", User::class.java) + + if (user == null) { + DisplayUtils.showSnackMessage(this, R.string.error_showing_encryption_dialog) + finish() + } + + val setupEncryptionDialogFragment = SetupEncryptionDialogFragment.newInstance(user, null) + supportFragmentManager.setFragmentResultListener( + SetupEncryptionDialogFragment.RESULT_REQUEST_KEY, + this + ) { requestKey, result -> + if (requestKey == SetupEncryptionDialogFragment.RESULT_REQUEST_KEY) { + if (!result.getBoolean(SetupEncryptionDialogFragment.RESULT_KEY_CANCELLED, false)) { + setResult( + SetupEncryptionDialogFragment.SETUP_ENCRYPTION_RESULT_CODE, + buildResultIntentFromBundle(result) + ) + } + } + finish() + } + setupEncryptionDialogFragment.show(supportFragmentManager, "setup_encryption") + } + + private fun buildResultIntentFromBundle(result: Bundle): Intent { + val intent = Intent() + intent.putExtra( + SetupEncryptionDialogFragment.SUCCESS, + result.getBoolean(SetupEncryptionDialogFragment.SUCCESS) + ) + intent.putExtra( + SetupEncryptionDialogFragment.ARG_FILE_PATH, + result.getInt(SetupEncryptionDialogFragment.ARG_FILE_PATH) + ) + return intent + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ShareActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ShareActivity.java new file mode 100644 index 000000000000..b7eab1152adc --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ShareActivity.java @@ -0,0 +1,192 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2017 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2016 Juan Carlos González Cabrero + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.drawable.LayerDrawable; +import android.os.Bundle; + +import com.nextcloud.client.account.User; +import com.owncloud.android.R; +import com.owncloud.android.databinding.ShareActivityBinding; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.SyncedFolderObserver; +import com.owncloud.android.datamodel.SyncedFolderProvider; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.operations.GetSharesForFileOperation; +import com.owncloud.android.ui.fragment.FileDetailSharingFragment; +import com.owncloud.android.ui.fragment.FileDetailsSharingProcessFragment; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.MimeTypeUtil; + +import java.util.Optional; + +import javax.inject.Inject; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; + +/** + * Activity for sharing files. + */ +public class ShareActivity extends FileActivity { + + private static final String TAG = ShareActivity.class.getSimpleName(); + + static final String TAG_SHARE_FRAGMENT = "SHARE_FRAGMENT"; + + @Inject + SyncedFolderProvider syncedFolderProvider; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + ShareActivityBinding binding = ShareActivityBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + OCFile file = getFile(); + Optional optionalUser = getUser(); + if (optionalUser.isEmpty()) { + finish(); + return; + } + + // Icon + if (file.isFolder()) { + boolean isAutoUploadFolder = SyncedFolderObserver.INSTANCE.isAutoUploadFolder(file, optionalUser.get()); + + Integer overlayIconId = file.getFileOverlayIconId(isAutoUploadFolder); + LayerDrawable drawable = MimeTypeUtil.getFolderIcon(preferences.isDarkModeEnabled(), overlayIconId, this, viewThemeUtils); + binding.shareFileIcon.setImageDrawable(drawable); + } else { + binding.shareFileIcon.setImageDrawable(MimeTypeUtil.getFileTypeIcon(file.getMimeType(), + file.getFileName(), + this, + viewThemeUtils)); + if (MimeTypeUtil.isImage(file)) { + String remoteId = String.valueOf(file.getRemoteId()); + Bitmap thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(remoteId); + if (thumbnail != null) { + binding.shareFileIcon.setImageBitmap(thumbnail); + } + } + } + + // Name + binding.shareFileName.setText(getResources().getString(R.string.share_file, file.getFileName())); + + viewThemeUtils.platform.colorViewBackground(binding.shareHeaderDivider); + + // Size + binding.shareFileSize.setText(DisplayUtils.bytesToHumanReadable(file.getFileLength())); + + Activity activity = this; + new Thread(() -> { + RemoteOperationResult result = new ReadFileRemoteOperation(getFile().getRemotePath()) + .execute(optionalUser.get(), + activity); + + if (result.isSuccess()) { + RemoteFile remoteFile = (RemoteFile) result.getData().get(0); + long length = remoteFile.getLength(); + + getFile().setFileLength(length); + runOnUiThread(() -> binding.shareFileSize.setText(DisplayUtils.bytesToHumanReadable(length))); + } + }).start(); + + if (savedInstanceState == null) { + // Add Share fragment on first creation + FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); + Fragment fragment = FileDetailSharingFragment.newInstance(getFile(), optionalUser.get()); + ft.replace(R.id.share_fragment_container, fragment, TAG_SHARE_FRAGMENT); + ft.commit(); + } + } + + @Override + protected void onStart() { + super.onStart(); + + // Load data into the list + Log_OC.d(TAG, "Refreshing lists on account set"); + refreshSharesFromStorageManager(); + } + + @Override + protected void doShareWith(String shareeName, ShareType shareType) { + getSupportFragmentManager().beginTransaction().replace(R.id.share_fragment_container, + FileDetailsSharingProcessFragment.newInstance(getFile(), + shareeName, + shareType, + false), + FileDetailsSharingProcessFragment.TAG) + .commit(); + } + + /** + * Updates the view associated to the activity after the finish of some operation over files in the current + * account. + * + * @param operation Removal operation performed. + * @param result Result of the removal. + */ + @Override + public void onRemoteOperationFinish(RemoteOperation operation, RemoteOperationResult result) { + super.onRemoteOperationFinish(operation, result); + + if (result.isSuccess() || + (operation instanceof GetSharesForFileOperation && + result.getCode() == RemoteOperationResult.ResultCode.SHARE_NOT_FOUND + ) + ) { + Log_OC.d(TAG, "Refreshing view on successful operation or finished refresh"); + refreshSharesFromStorageManager(); + } + } + + /** + * Updates the view, reading data from {@link com.owncloud.android.datamodel.FileDataStorageManager}. + */ + private void refreshSharesFromStorageManager() { + + FileDetailSharingFragment shareFileFragment = getShareFileFragment(); + if (shareFileFragment != null + && shareFileFragment.isAdded()) { // only if added to the view hierarchy!! + shareFileFragment.refreshCapabilitiesFromDB(); + shareFileFragment.refreshSharesFromDB(); + } + } + + /** + * Shortcut to get access to the {@link FileDetailSharingFragment} instance, if any + * + * @return A {@link FileDetailSharingFragment} instance, or null + */ + private FileDetailSharingFragment getShareFileFragment() { + return (FileDetailSharingFragment) getSupportFragmentManager().findFragmentByTag(TAG_SHARE_FRAGMENT); + } + + @Override + public void onShareProcessClosed() { + finish(); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java new file mode 100644 index 000000000000..41524cda38b2 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java @@ -0,0 +1,216 @@ +/* + * Nextcloud Android client application + * + * @author David Luhmer + * @author Andy Scherzinger + * Copyright (C) 2018 David Luhmer + * Copyright (C) 2018 Andy Scherzinger + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.ui.activity; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.nextcloud.android.sso.Constants; +import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.databinding.DialogSsoGrantPermissionBinding; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.EncryptionUtils; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.util.UUID; + +import javax.inject.Inject; + +import androidx.appcompat.app.AlertDialog; + +import static com.nextcloud.android.sso.Constants.DELIMITER; +import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED; +import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND; +import static com.nextcloud.android.sso.Constants.NEXTCLOUD_FILES_ACCOUNT; +import static com.nextcloud.android.sso.Constants.NEXTCLOUD_SSO; +import static com.nextcloud.android.sso.Constants.NEXTCLOUD_SSO_EXCEPTION; +import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; + + +/** + * Activity for granting access rights to a Nextcloud account, used for SSO. + */ +public class SsoGrantPermissionActivity extends BaseActivity { + + private static final String TAG = SsoGrantPermissionActivity.class.getCanonicalName(); + + private String packageName; + private Account account; + + @Inject ViewThemeUtils.Factory themeUtilsFactory; + private ViewThemeUtils viewThemeUtils; + + private AlertDialog dialog; + + private DialogSsoGrantPermissionBinding binding; + + public DialogSsoGrantPermissionBinding getBinding() { + return binding; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + viewThemeUtils = themeUtilsFactory.withDefaultSchemes(); + + binding = DialogSsoGrantPermissionBinding.inflate(getLayoutInflater()); + + ComponentName callingActivity = getCallingActivity(); + + if (callingActivity != null) { + packageName = callingActivity.getPackageName(); + final String appName = getAppNameForPackage(packageName); + account = IntentExtensionsKt.getParcelableArgument(getIntent(), NEXTCLOUD_FILES_ACCOUNT, Account.class); + + if (account != null) { + final SpannableStringBuilder dialogText = makeSpecialPartsBold( + getString(R.string.single_sign_on_request_token, appName, account.name), + appName, + account.name); + binding.permissionText.setText(dialogText); + } + + try { + if (packageName != null) { + Drawable appIcon = getPackageManager().getApplicationIcon(packageName); + binding.appIcon.setImageDrawable(appIcon); + } + } catch (PackageManager.NameNotFoundException e) { + Log_OC.e(TAG, "Error retrieving app icon", e); + } + + MaterialAlertDialogBuilder builder = getMaterialAlertDialogBuilder(); + + builder + .setView(binding.getRoot()) + .setCancelable(false) + .setPositiveButton(R.string.permission_allow, (dialog, which) -> grantPermission()) + .setNegativeButton(R.string.permission_deny, (dialog, which) -> exitFailed()); + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, builder); + + dialog = builder.create(); + dialog.show(); + + Log_OC.v(TAG, "TOKEN-REQUEST: Calling Package: " + packageName); + Log_OC.v(TAG, "TOKEN-REQUEST: App Name: " + appName); + } else { + // Activity was not started using startActivityForResult! + Log_OC.e(TAG, "Calling Package is null"); + setResultAndExit("Request was not executed properly. Use startActivityForResult()"); + } + } + + public MaterialAlertDialogBuilder getMaterialAlertDialogBuilder() { + return new MaterialAlertDialogBuilder(this); + } + + @Override + protected void onStart() { + super.onStart(); + viewThemeUtils.platform.colorTextButtons(dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)); + } + + private SpannableStringBuilder makeSpecialPartsBold(String text, String... toBeStyledText) { + SpannableStringBuilder ssb = new SpannableStringBuilder(text); + for (String textBlock : toBeStyledText) { + int start = text.indexOf(textBlock); + int end = start + textBlock.length(); + ssb.setSpan(new StyleSpan(Typeface.BOLD), start, end, 0); + ssb.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.text_color)), start, end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + return ssb; + } + + private void setResultAndExit(String exception) { + Intent data = new Intent(); + data.putExtra(NEXTCLOUD_SSO_EXCEPTION, exception); + setResult(RESULT_CANCELED, data); + finish(); + } + + private String getAppNameForPackage(String pkg) { + final PackageManager pm = getApplicationContext().getPackageManager(); + ApplicationInfo ai = null; + try { + ai = pm.getApplicationInfo(pkg, 0); + } catch (final PackageManager.NameNotFoundException e) { + Log_OC.e(TAG, "Error fetching app name for package", e); + } + return (String) (ai != null ? pm.getApplicationLabel(ai) : "(unknown)"); + } + + private void exitFailed() { + setResultAndExit(EXCEPTION_ACCOUNT_ACCESS_DECLINED); + } + + private void grantPermission() { + // create token + SharedPreferences sharedPreferences = getSharedPreferences(SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE); + String token = UUID.randomUUID().toString().replaceAll("-", ""); + + String hashedTokenWithSalt = EncryptionUtils.generateSHA512(token); + + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(packageName + DELIMITER + account.name, hashedTokenWithSalt); + editor.apply(); + + String serverUrl; + String userId; + try { + OwnCloudAccount ocAccount = new OwnCloudAccount(account, this); + serverUrl = ocAccount.getBaseUri().toString(); + AccountManager accountManager = AccountManager.get(this); + userId = accountManager.getUserData(account, + com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID); + } catch (AccountUtils.AccountNotFoundException e) { + Log_OC.e(TAG, "Account not found"); + setResultAndExit(EXCEPTION_ACCOUNT_NOT_FOUND); + return; + } + + final Bundle result = new Bundle(); + result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); + result.putString(AccountManager.KEY_ACCOUNT_TYPE, MainApp.getAccountType(this)); + result.putString(AccountManager.KEY_AUTHTOKEN, NEXTCLOUD_SSO); + result.putString(Constants.SSO_USER_ID, userId); + result.putString(Constants.SSO_TOKEN, token); + result.putString(Constants.SSO_SERVER_URL, serverUrl); + + Intent data = new Intent(); + data.putExtra(NEXTCLOUD_SSO, result); + setResult(RESULT_OK, data); + finish(); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/StorageMigration.java b/app/src/main/java/com/owncloud/android/ui/activity/StorageMigration.java new file mode 100644 index 000000000000..02a1fb67b4d8 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/StorageMigration.java @@ -0,0 +1,503 @@ +/* + * Nextcloud Android client application + * + * @author Bartosz Przybylski + * Copyright (C) 2016 Bartosz Przybylski + * Copyright (C) 2016 Nextcloud + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.annotation.SuppressLint; +import android.app.ProgressDialog; +import android.content.ContentResolver; +import android.content.Context; +import android.os.AsyncTask; +import android.view.View; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.nextcloud.client.account.User; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.res.ResourcesCompat; + +/** + * @author Bartosz Przybylski + */ +public class StorageMigration { + private static final String TAG = StorageMigration.class.getName(); + + private final Context mContext; + private final User user; + private final String mSourceStoragePath; + private final String mTargetStoragePath; + private final ViewThemeUtils viewThemeUtils; + + private StorageMigrationProgressListener mListener; + + public interface StorageMigrationProgressListener { + void onStorageMigrationFinished(String storagePath, boolean succeed); + void onCancelMigration(); + } + + public StorageMigration(Context context, User user, String sourcePath, String targetPath, ViewThemeUtils viewThemeUtils) { + mContext = context; + this.user = user; + mSourceStoragePath = sourcePath; + mTargetStoragePath = targetPath; + this.viewThemeUtils = viewThemeUtils; + } + + public void setStorageMigrationProgressListener(StorageMigrationProgressListener listener) { + mListener = listener; + } + + public void migrate() { + if (storageFolderAlreadyExists()) { + askToOverride(); + } else { + ProgressDialog progressDialog = createMigrationProgressDialog(); + progressDialog.show(); + new FileMigrationTask( + mContext, + user, + mSourceStoragePath, + mTargetStoragePath, + progressDialog, + mListener, + viewThemeUtils).execute(); + + progressDialog.getButton(ProgressDialog.BUTTON_POSITIVE).setVisibility(View.GONE); + } + } + + private boolean storageFolderAlreadyExists() { + File f = new File(mTargetStoragePath, MainApp.getDataFolder()); + return f.exists() && f.isDirectory(); + } + + public static void a(ViewThemeUtils viewThemeUtils, Context context) { + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context) + .setMessage(R.string.file_migration_directory_already_exists) + .setCancelable(true) + .setOnCancelListener(dialogInterface -> { + + }) + .setNegativeButton(R.string.common_cancel, (dialogInterface, i) -> { + + }) + .setNeutralButton(R.string.file_migration_use_data_folder, (dialogInterface, i) -> { + + }) + .setPositiveButton(R.string.file_migration_override_data_folder, (dialogInterface, i) -> { + + }); + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(context, builder); + + AlertDialog alertDialog = builder.create(); + + alertDialog.show(); + } + + private void askToOverride() { + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(mContext) + .setMessage(R.string.file_migration_directory_already_exists) + .setCancelable(true) + .setOnCancelListener(dialogInterface -> { + if (mListener != null) { + mListener.onCancelMigration(); + } + }) + .setNegativeButton(R.string.common_cancel, (dialogInterface, i) -> { + if (mListener != null) { + mListener.onCancelMigration(); + } + }) + .setNeutralButton(R.string.file_migration_use_data_folder, (dialogInterface, i) -> { + ProgressDialog progressDialog = createMigrationProgressDialog(); + progressDialog.show(); + new StoragePathSwitchTask( + mContext, + user, + mSourceStoragePath, + mTargetStoragePath, + progressDialog, + mListener, + viewThemeUtils).execute(); + + progressDialog.getButton(ProgressDialog.BUTTON_POSITIVE).setVisibility(View.GONE); + + }) + .setPositiveButton(R.string.file_migration_override_data_folder, (dialogInterface, i) -> { + ProgressDialog progressDialog = createMigrationProgressDialog(); + progressDialog.show(); + new FileMigrationTask( + mContext, + user, + mSourceStoragePath, + mTargetStoragePath, + progressDialog, + mListener, + viewThemeUtils).execute(); + + progressDialog.getButton(ProgressDialog.BUTTON_POSITIVE).setVisibility(View.GONE); + }); + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(mContext, builder); + builder.create().show(); + } + + private ProgressDialog createMigrationProgressDialog() { + ProgressDialog progressDialog = new ProgressDialog(mContext); + progressDialog.setCancelable(false); + progressDialog.setTitle(R.string.file_migration_dialog_title); + progressDialog.setMessage(mContext.getString(R.string.file_migration_preparing)); + progressDialog.setButton( + ProgressDialog.BUTTON_POSITIVE, + mContext.getString(R.string.drawer_close), + (dialogInterface, i) -> dialogInterface.dismiss()); + return progressDialog; + } + + private static abstract class FileMigrationTaskBase extends AsyncTask { + protected String mStorageSource; + protected String mStorageTarget; + @SuppressLint("StaticFieldLeak") protected Context mContext; + protected User user; + protected ProgressDialog mProgressDialog; + protected StorageMigrationProgressListener mListener; + + protected String mAuthority; + protected Account[] mOcAccounts; + protected ViewThemeUtils viewThemeUtils; + + public FileMigrationTaskBase(Context context, + User user, + String source, + String target, + ProgressDialog progressDialog, + StorageMigrationProgressListener listener, + ViewThemeUtils viewThemeUtils) throws SecurityException { + mContext = context; + this.user = user; + mStorageSource = source; + mStorageTarget = target; + mProgressDialog = progressDialog; + mListener = listener; + this.viewThemeUtils = viewThemeUtils; + mAuthority = mContext.getString(R.string.authority); + mOcAccounts = AccountManager.get(mContext).getAccountsByType(MainApp.getAccountType(context)); + } + + @Override + protected void onProgressUpdate(Integer... progress) { + if (progress.length > 1 && progress[0] != 0) { + mProgressDialog.setMessage(mContext.getString(progress[0])); + } + } + + @Override + protected void onPostExecute(Integer code) { + if (code != 0) { + mProgressDialog.setMessage(mContext.getString(code)); + } else { + mProgressDialog.setMessage(mContext.getString(R.string.file_migration_ok_finished)); + } + + boolean succeed = code == 0; + if (succeed) { + mProgressDialog.hide(); + } else { + + if (code == R.string.file_migration_failed_not_readable) { + mProgressDialog.hide(); + askToStillMove(); + } else { + mProgressDialog.getButton(ProgressDialog.BUTTON_POSITIVE).setVisibility(View.VISIBLE); + mProgressDialog.setIndeterminateDrawable(ResourcesCompat.getDrawable(mContext.getResources(), + R.drawable.image_fail, + null)); + } + } + + if (mListener != null) { + mListener.onStorageMigrationFinished(succeed ? mStorageTarget : mStorageSource, succeed); + } + } + + private void askToStillMove() { + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(mContext) + .setTitle(R.string.file_migration_source_not_readable_title) + .setMessage(mContext.getString(R.string.file_migration_source_not_readable, mStorageTarget)) + .setNegativeButton(R.string.common_no, (dialogInterface, i) -> dialogInterface.dismiss()) + .setPositiveButton(R.string.common_yes, (dialogInterface, i) -> { + if (mListener != null) { + mListener.onStorageMigrationFinished(mStorageTarget, true); + } + }); + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(mContext, builder); + builder.create().show(); + } + + protected boolean[] saveAccountsSyncStatus() { + boolean[] syncs = new boolean[mOcAccounts.length]; + for (int i = 0; i < mOcAccounts.length; ++i) { + syncs[i] = ContentResolver.getSyncAutomatically(mOcAccounts[i], mAuthority); + } + return syncs; + } + + protected void stopAccountsSyncing() { + for (Account ocAccount : mOcAccounts) { + ContentResolver.setSyncAutomatically(ocAccount, mAuthority, false); + } + } + + protected void waitForUnfinishedSynchronizations() { + for (Account ocAccount : mOcAccounts) { + while (ContentResolver.isSyncActive(ocAccount, mAuthority)) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Log_OC.w(TAG, "Thread interrupted while waiting for account to end syncing"); + Thread.currentThread().interrupt(); + } + } + } + } + + protected void restoreAccountsSyncStatus(boolean... oldSync) { + // If we don't have the old sync statuses, then + // probably migration failed even before saving states, + // which is weird and should be investigated. + // But its better than crashing on ArrayOutOfBounds. + if (oldSync == null) { + return; + } + for (int i = 0; i < mOcAccounts.length; ++i) { + ContentResolver.setSyncAutomatically(mOcAccounts[i], mAuthority, oldSync[i]); + } + } + } + + static private class StoragePathSwitchTask extends FileMigrationTaskBase { + + public StoragePathSwitchTask(Context context, + User user, + String source, + String target, + ProgressDialog progressDialog, + StorageMigrationProgressListener listener, + ViewThemeUtils viewThemeUtils) { + super(context, user, source, target, progressDialog, listener, viewThemeUtils); + } + + @Override + protected Integer doInBackground(Void... voids) { + publishProgress(R.string.file_migration_preparing); + + boolean[] syncStates = null; + try { + publishProgress(R.string.file_migration_saving_accounts_configuration); + syncStates = saveAccountsSyncStatus(); + + publishProgress(R.string.file_migration_waiting_for_unfinished_sync); + stopAccountsSyncing(); + waitForUnfinishedSynchronizations(); + } finally { + publishProgress(R.string.file_migration_restoring_accounts_configuration); + restoreAccountsSyncStatus(syncStates); + } + + return 0; + } + } + + static private class FileMigrationTask extends FileMigrationTaskBase { + private static class MigrationException extends Exception { + private static final long serialVersionUID = -4575848188034992066L; + private int mResId; + + MigrationException(int resId) { + super(); + this.mResId = resId; + } + + MigrationException(int resId, Throwable t) { + super(t); + this.mResId = resId; + } + + private int getResId() { return mResId; } + } + + public FileMigrationTask(Context context, + User user, + String source, + String target, + ProgressDialog progressDialog, + StorageMigrationProgressListener listener, + ViewThemeUtils viewThemeUtils) { + super(context, user, source, target, progressDialog, listener, viewThemeUtils); + } + + @Override + protected Integer doInBackground(Void... args) { + publishProgress(R.string.file_migration_preparing); + + boolean[] syncState = null; + + try { + File dstFile = new File(mStorageTarget + File.separator + MainApp.getDataFolder()); + deleteRecursive(dstFile); + try { + Files.delete(dstFile.toPath()); + } catch (IOException e) { + Log_OC.e(TAG, "Could not delete destination file: " + dstFile.getAbsolutePath(), e); + } + + File srcFile = new File(mStorageSource + File.separator + MainApp.getDataFolder()); + + try { + Files.createDirectories(srcFile.toPath()); + } catch (IOException e) { + Log_OC.e(TAG, "Could not create directory: " + srcFile.getAbsolutePath(), e); + } + + publishProgress(R.string.file_migration_checking_destination); + + checkDestinationAvailability(); + + publishProgress(R.string.file_migration_saving_accounts_configuration); + syncState = saveAccountsSyncStatus(); + + publishProgress(R.string.file_migration_waiting_for_unfinished_sync); + stopAccountsSyncing(); + waitForUnfinishedSynchronizations(); + + publishProgress(R.string.file_migration_migrating); + copyFiles(); + + publishProgress(R.string.file_migration_updating_index); + updateIndex(mContext); + + publishProgress(R.string.file_migration_cleaning); + cleanup(); + + } catch (MigrationException e) { + rollback(); + return e.getResId(); + } finally { + publishProgress(R.string.file_migration_restoring_accounts_configuration); + restoreAccountsSyncStatus(syncState); + } + + publishProgress(R.string.file_migration_ok_finished); + + return 0; + } + + + private void checkDestinationAvailability() throws MigrationException { + File srcFile = new File(mStorageSource); + File dstFile = new File(mStorageTarget); + + if (!dstFile.canRead() || !srcFile.canRead()) { + throw new MigrationException(R.string.file_migration_failed_not_readable); + } + + if (!dstFile.canWrite() || !srcFile.canWrite()) { + throw new MigrationException(R.string.file_migration_failed_not_writable); + } + + if (new File(dstFile, MainApp.getDataFolder()).exists()) { + throw new MigrationException(R.string.file_migration_failed_dir_already_exists); + } + + try { + if (dstFile.getFreeSpace() < FileStorageUtils.getFolderSize(new File(srcFile, MainApp.getDataFolder()))) { + throw new MigrationException(R.string.file_migration_failed_not_enough_space); + } + } catch (MigrationException e) { + throw new RuntimeException(e); + } + } + + private void copyFiles() throws MigrationException { + File srcFile = new File(mStorageSource + File.separator + MainApp.getDataFolder()); + File dstFile = new File(mStorageTarget + File.separator + MainApp.getDataFolder()); + + copyDirs(srcFile, dstFile); + } + + private void copyDirs(File src, File dst) throws MigrationException { + if (!dst.mkdirs()) { + throw new MigrationException(R.string.file_migration_failed_while_coping); + } + + for (File f : src.listFiles()) { + if (f.isDirectory()) { + copyDirs(f, new File(dst, f.getName())); + } else if (!FileStorageUtils.copyFile(f, new File(dst, f.getName()))) { + throw new MigrationException(R.string.file_migration_failed_while_coping); + } + } + + } + + private void updateIndex(Context context) throws MigrationException { + FileDataStorageManager manager = new FileDataStorageManager(user, context.getContentResolver()); + + try { + manager.migrateStoredFiles(mStorageSource, mStorageTarget); + } catch (Exception e) { + Log_OC.e(TAG,e.getMessage(),e); + throw new MigrationException(R.string.file_migration_failed_while_updating_index, e); + } + } + + private void cleanup() { + File srcFile = new File(mStorageSource + File.separator + MainApp.getDataFolder()); + if (!deleteRecursive(srcFile)) { + Log_OC.w(TAG, "Migration cleanup step failed"); + } + try { + Files.delete(srcFile.toPath()); + } catch (IOException e) { + Log_OC.e(TAG, "Could not delete source file: " + srcFile.getAbsolutePath(), e); + } + } + + private boolean deleteRecursive(File f) { + boolean res = true; + if (f.isDirectory()) { + for (File c : f.listFiles()) { + res = deleteRecursive(c) && res; + } + } + return f.delete() && res; + } + + private void rollback() { + File dstFile = new File(mStorageTarget + File.separator + MainApp.getDataFolder()); + if (dstFile.exists() && !dstFile.delete()) { + Log_OC.w(TAG, "Rollback step failed"); + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt new file mode 100644 index 000000000000..9dd1d31c95a7 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt @@ -0,0 +1,891 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2016 Andy Scherzinger + * SPDX-FileCopyrightText: 2016 Nextcloud + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Looper +import android.text.TextUtils +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.appinfo.AppInfo +import com.nextcloud.client.core.Clock +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.jobs.MediaFoldersDetectionWork +import com.nextcloud.client.jobs.NotificationWork +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.preferences.SubFolderRule +import com.nextcloud.utils.BatteryOptimizationHelper +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.isDialogFragmentReady +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.StoragePermissionWarningBannerBinding +import com.owncloud.android.databinding.SyncedFoldersLayoutBinding +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.MediaFolder +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.MediaProvider +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.SyncedFolderDisplayItem +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.adapter.SyncedFolderAdapter +import com.owncloud.android.ui.adapter.storagePermissionBanner.setup +import com.owncloud.android.ui.decoration.MediaGridItemDecoration +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.SyncedFolderPreferencesDialogFragment +import com.owncloud.android.ui.dialog.SyncedFolderPreferencesDialogFragment.OnSyncedFolderPreferenceListener +import com.owncloud.android.ui.dialog.parcel.SyncedFolderParcelable +import com.owncloud.android.utils.PermissionUtil +import com.owncloud.android.utils.SyncedFolderUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Locale +import javax.inject.Inject + +/** + * Activity displaying all auto-synced folders and/or instant upload media folders. + */ +@Suppress("TooManyFunctions", "LargeClass") +class SyncedFoldersActivity : + FileActivity(), + SyncedFolderAdapter.ClickListener, + OnSyncedFolderPreferenceListener, + Injectable { + + companion object { + private const val SYNCED_FOLDER_PREFERENCES_DIALOG_TAG = "SYNCED_FOLDER_PREFERENCES_DIALOG" + private const val SUB_FOLDER_WARNING_DIALOG_TAG = "SUB_FOLDER_WARNING_DIALOG_TAG" + + // yes, there is a typo in this value + private const val KEY_SYNCED_FOLDER_INITIATED_PREFIX = "syncedFolderIntitiated_" + private val PRIORITIZED_FOLDERS = arrayOf("Camera", "Screenshots") + private val TAG = SyncedFoldersActivity::class.java.simpleName + + /** + * Sorts list of [SyncedFolderDisplayItem]s. + * + * @param syncFolderItemList list of items to be sorted + * @return sorted list of items + */ + @JvmStatic + @Suppress("ComplexMethod") + fun sortSyncedFolderItems(syncFolderItemList: List): List { + return syncFolderItemList.sortedWith { f1, f2 -> + if (f1 == null && f2 == null) { + 0 + } else if (f1 == null) { + -1 + } else if (f2 == null) { + 1 + } else if (f1.isEnabled && f2.isEnabled) { + when { + f1.folderName == null -> -1 + + f2.folderName == null -> 1 + + else -> f1.folderName.lowercase(Locale.getDefault()).compareTo( + f2.folderName.lowercase(Locale.getDefault()) + ) + } + } else if (f1.folderName == null && f2.folderName == null) { + 0 + } else if (f1.isEnabled) { + -1 + } else if (f2.isEnabled) { + 1 + } else if (f1.folderName == null) { + -1 + } else if (f2.folderName == null) { + 1 + } else { + for (folder in PRIORITIZED_FOLDERS) { + if (folder == f1.folderName && folder == f2.folderName) { + return@sortedWith 0 + } else if (folder == f1.folderName) { + return@sortedWith -1 + } else if (folder == f2.folderName) { + return@sortedWith 1 + } + } + f1.folderName.lowercase(Locale.getDefault()).compareTo( + f2.folderName.lowercase(Locale.getDefault()) + ) + } + } + } + } + + @Inject + lateinit var powerManagementService: PowerManagementService + + @Inject + lateinit var clock: Clock + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var appInfo: AppInfo + + lateinit var binding: SyncedFoldersLayoutBinding + lateinit var adapter: SyncedFolderAdapter + + private var dialogFragment: SyncedFolderPreferencesDialogFragment? = null + private var path: String? = null + private var type = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = SyncedFoldersLayoutBinding.inflate(layoutInflater) + setContentView(binding.root) + if (intent != null && intent.extras != null) { + val accountName = intent.extras!!.getString(NotificationWork.KEY_NOTIFICATION_ACCOUNT) + val optionalUser = user + if (optionalUser.isPresent && accountName != null) { + val user = optionalUser.get() + if (!accountName.equals(user.accountName, ignoreCase = true)) { + accountManager.setCurrentOwnCloudAccount(accountName) + setUser(userAccountManager.user) + } + } + path = intent.getStringExtra(MediaFoldersDetectionWork.KEY_MEDIA_FOLDER_PATH) + type = intent.getIntExtra(MediaFoldersDetectionWork.KEY_MEDIA_FOLDER_TYPE, -1) + + // Cancel notification + val notificationId = intent.getIntExtra(MediaFoldersDetectionWork.NOTIFICATION_ID, 0) + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(notificationId) + } + + // setup toolbar + setupToolbar() + updateActionBarTitleAndHomeButtonByString(getString(R.string.drawer_synced_folders)) + setupDrawer(menuItemId) + setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + if (supportActionBar != null) { + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + } + if (mDrawerToggle != null) { + mDrawerToggle.isDrawerIndicatorEnabled = false + } + + setupContent() + if (themeUtils.themingEnabled(this)) { + setTheme(R.style.FallbackThemingTheme) + } + binding.emptyList.emptyListViewAction.setOnClickListener { showHiddenItems() } + setupStoragePermissionWarningBanner() + } + + override fun getMenuItemId(): Int = R.id.nav_settings + + override fun onResume() { + super.onResume() + highlightNavigationViewItem(menuItemId) + } + + fun setupStoragePermissionWarningBanner() { + val storagePermissionWarningBanner = binding.storagePermissionWarningBanner.root + StoragePermissionWarningBannerBinding.bind(storagePermissionWarningBanner).apply { + setup(this@SyncedFoldersActivity, R.string.storage_permission_banner_auto_upload_text) + } + storagePermissionWarningBanner.setVisibleIf(!PermissionUtil.checkStoragePermission(this)) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.activity_synced_folders, menu) + return true + } + + fun buildPowerCheckDialog(): AlertDialog { + val builder = MaterialAlertDialogBuilder(this) + .setPositiveButton(R.string.common_ok) { dialog, _ -> dialog.dismiss() } + .setTitle(R.string.autoupload_disable_power_save_check) + .setMessage(getString(R.string.power_save_check_dialog_message)) + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, builder) + + return builder.create() + } + + @VisibleForTesting + fun showPowerCheckDialog() { + buildPowerCheckDialog().show() + } + + /** + * sets up the UI elements and loads all media/synced folders. + */ + private fun setupContent() { + val gridWidth = resources.getInteger(R.integer.media_grid_width) + val lightVersion = resources.getBoolean(R.bool.syncedFolder_light) + adapter = SyncedFolderAdapter( + lifecycleScope, + this, + clock, + gridWidth, + this, + lightVersion, + viewThemeUtils, + powerManagementService, + connectivityService + ) + binding.emptyList.emptyListIcon.setImageResource(R.drawable.nav_synced_folders) + viewThemeUtils.material.colorMaterialButtonPrimaryFilled(binding.emptyList.emptyListViewAction) + val lm = GridLayoutManager(this, gridWidth) + adapter.setLayoutManager(lm) + val spacing = resources.getDimensionPixelSize(R.dimen.media_grid_spacing) + binding.list.addItemDecoration(MediaGridItemDecoration(spacing)) + binding.list.layoutManager = lm + binding.list.adapter = adapter + load(getItemsDisplayedPerFolder(), false) + } + + private fun showHiddenItems() { + if (adapter.sectionCount == 0 && adapter.unfilteredSectionCount > adapter.sectionCount) { + adapter.toggleHiddenItemsVisibility() + binding.emptyList.emptyListView.visibility = View.GONE + binding.list.visibility = View.VISIBLE + } + } + + /** + * loads all media/synced folders, adds them to the recycler view adapter and shows the list. + * + * @param perFolderMediaItemLimit the amount of media items to be loaded/shown per media folder + */ + @SuppressLint("NotifyDataSetChanged") + private fun load(perFolderMediaItemLimit: Int, force: Boolean) { + if (adapter.itemCount > 0 && !force) { + return + } + + showLoadingContent() + lifecycleScope.launch(Dispatchers.IO) { + val mediaFolders = MediaProvider.getImageFolders( + contentResolver, + perFolderMediaItemLimit, + this@SyncedFoldersActivity, + false + ) + mediaFolders.addAll( + MediaProvider.getVideoFolders( + contentResolver, + perFolderMediaItemLimit, + this@SyncedFoldersActivity, + false + ) + ) + + val syncedFolderArrayList = syncedFolderProvider.syncedFolders + val currentAccountSyncedFoldersList: MutableList = ArrayList() + val user = userAccountManager.user + for (syncedFolder in syncedFolderArrayList) { + if (syncedFolder.account == user.accountName) { + val folder = File(syncedFolder.localPath) + + // delete non-existing & disabled synced folders + if (!folder.exists() && !syncedFolder.isEnabled) { + syncedFolderProvider.deleteSyncedFolder(syncedFolder.id) + } else { + currentAccountSyncedFoldersList.add(syncedFolder) + } + } + } + + val syncFolderItems = sortSyncedFolderItems( + mergeFolderData(currentAccountSyncedFoldersList, mediaFolders) + ).filterNotNull() + + withContext(Dispatchers.Main) { + adapter.setSyncFolderItems(syncFolderItems) + adapter.notifyDataSetChanged() + showList() + + if (!TextUtils.isEmpty(path)) { + val section = adapter.getSectionByLocalPathAndType(path, type) + if (section >= 0) { + adapter.get(section)?.let { + onSyncFolderSettingsClick(section, it) + } + } + } + } + } + } + + /** + * merges two lists of [SyncedFolder] and [MediaFolder] items into one of SyncedFolderItems. + * + * @param syncedFolders the synced folders + * @param mediaFolders the media folders + * @return the merged list of SyncedFolderItems + */ + @Suppress("NestedBlockDepth") // legacy code + private fun mergeFolderData( + syncedFolders: List, + mediaFolders: List + ): List { + val syncedFoldersMap = createSyncedFoldersMap(syncedFolders) + val result: MutableList = ArrayList() + for (mediaFolder in mediaFolders) { + if (syncedFoldersMap.containsKey(mediaFolder.absolutePath + "-" + mediaFolder.type)) { + val syncedFolder = syncedFoldersMap[mediaFolder.absolutePath + "-" + mediaFolder.type] + syncedFoldersMap.remove(mediaFolder.absolutePath + "-" + mediaFolder.type) + if (syncedFolder != null && SyncedFolderUtils.isQualifyingMediaFolder(syncedFolder)) { + if (MediaFolderType.CUSTOM == syncedFolder.type) { + result.add(createSyncedFolderWithoutMediaFolder(syncedFolder)) + } else { + result.add(createSyncedFolder(syncedFolder, mediaFolder)) + } + } + } else { + if (SyncedFolderUtils.isQualifyingMediaFolder(mediaFolder)) { + result.add(createSyncedFolderFromMediaFolder(mediaFolder)) + } + } + } + for (syncedFolder in syncedFoldersMap.values) { + result.add(createSyncedFolderWithoutMediaFolder(syncedFolder)) + } + return result + } + + private fun createSyncedFolderWithoutMediaFolder(syncedFolder: SyncedFolder): SyncedFolderDisplayItem { + val localFolder = File(syncedFolder.localPath) + val files = SyncedFolderUtils.getFileList(localFolder) + val filePaths = getDisplayFilePathList(files) + return SyncedFolderDisplayItem( + syncedFolder.id, + syncedFolder.localPath, + syncedFolder.remotePath, + syncedFolder.isWifiOnly, + syncedFolder.isChargingOnly, + syncedFolder.isExisting, + syncedFolder.isSubfolderByDate, + syncedFolder.account, + syncedFolder.uploadAction, + syncedFolder.nameCollisionPolicyInt, + syncedFolder.isEnabled, + clock.currentTime, + filePaths, + localFolder.name, + files.size.toLong(), + syncedFolder.type, + syncedFolder.isHidden, + syncedFolder.subfolderRule, + syncedFolder.isExcludeHidden, + syncedFolder.lastScanTimestampMs + ) + } + + /** + * creates a SyncedFolderDisplayItem merging a [SyncedFolder] and a [MediaFolder] object instance. + * + * @param syncedFolder the synced folder object + * @param mediaFolder the media folder object + * @return the created SyncedFolderDisplayItem + */ + private fun createSyncedFolder(syncedFolder: SyncedFolder, mediaFolder: MediaFolder): SyncedFolderDisplayItem = + SyncedFolderDisplayItem( + syncedFolder.id, + syncedFolder.localPath, + syncedFolder.remotePath, + syncedFolder.isWifiOnly, + syncedFolder.isChargingOnly, + syncedFolder.isExisting, + syncedFolder.isSubfolderByDate, + syncedFolder.account, + syncedFolder.uploadAction, + syncedFolder.nameCollisionPolicyInt, + syncedFolder.isEnabled, + clock.currentTime, + mediaFolder.filePaths, + mediaFolder.folderName, + mediaFolder.numberOfFiles, + mediaFolder.type, + syncedFolder.isHidden, + syncedFolder.subfolderRule, + syncedFolder.isExcludeHidden, + syncedFolder.lastScanTimestampMs + ) + + /** + * creates a [SyncedFolderDisplayItem] based on a [MediaFolder] object instance. + * + * @param mediaFolder the media folder object + * @return the created SyncedFolderDisplayItem + */ + private fun createSyncedFolderFromMediaFolder(mediaFolder: MediaFolder): SyncedFolderDisplayItem = + SyncedFolderDisplayItem( + SyncedFolder.UNPERSISTED_ID, + mediaFolder.absolutePath, + getString(R.string.instant_upload_path) + "/" + mediaFolder.folderName, + true, + false, + true, + false, + account.name, + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + NameCollisionPolicy.ASK_USER.serialize(), + false, + clock.currentTime, + mediaFolder.filePaths, + mediaFolder.folderName, + mediaFolder.numberOfFiles, + mediaFolder.type, + false, + SubFolderRule.YEAR_MONTH, + false, + SyncedFolder.NOT_SCANNED_YET + ) + + private fun getItemsDisplayedPerFolder(): Int = resources.getInteger(R.integer.media_grid_width) * 2 + + private fun getDisplayFilePathList(files: List?): List? { + if (!files.isNullOrEmpty()) { + return files.take(getItemsDisplayedPerFolder()) + .map { it.absolutePath } + } + return null + } + + /** + * creates a lookup map for a list of given [SyncedFolder]s with their local path as the key. + * + * @param syncFolders list of [SyncedFolder]s + * @return the lookup map for [SyncedFolder]s + */ + private fun createSyncedFoldersMap(syncFolders: List?): MutableMap { + val result: MutableMap = HashMap() + if (syncFolders != null) { + for (syncFolder in syncFolders) { + result[syncFolder.localPath + "-" + syncFolder.type] = syncFolder + } + } + return result + } + + /** + * show recycler view list or the empty message info (in case list is empty). + */ + private fun showList() { + binding.list.visibility = View.VISIBLE + binding.loadingContent.visibility = View.GONE + checkAndShowEmptyListContent() + } + + private fun checkAndShowEmptyListContent() { + if (adapter.sectionCount == 0 && adapter.unfilteredSectionCount > adapter.sectionCount) { + binding.emptyList.emptyListView.visibility = View.VISIBLE + val hiddenFoldersCount = adapter.hiddenFolderCount + showEmptyContent( + getString(R.string.drawer_synced_folders), + resources.getQuantityString( + R.plurals.synced_folders_show_hidden_folders, + hiddenFoldersCount, + hiddenFoldersCount + ), + resources.getQuantityString( + R.plurals.synced_folders_show_hidden_folders, + hiddenFoldersCount, + hiddenFoldersCount + ) + ) + } else if (adapter.sectionCount == 0 && adapter.unfilteredSectionCount == 0) { + binding.emptyList.emptyListView.visibility = View.VISIBLE + showEmptyContent( + getString(R.string.drawer_synced_folders), + getString(R.string.synced_folders_no_results) + ) + } else { + binding.emptyList.emptyListView.visibility = View.GONE + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + var result = true + when (item.itemId) { + android.R.id.home -> finish() + + R.id.action_create_custom_folder -> { + Log_OC.d(TAG, "Show custom folder dialog") + if (PermissionUtil.checkStoragePermission(this)) { + val emptyCustomFolder = SyncedFolderDisplayItem( + SyncedFolder.UNPERSISTED_ID, + null, + null, + true, + false, + true, + false, + account.name, + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + NameCollisionPolicy.ASK_USER.serialize(), + false, + clock.currentTime, + null, + MediaFolderType.CUSTOM, + false, + SubFolderRule.YEAR_MONTH, + false, + SyncedFolder.NOT_SCANNED_YET + ) + onSyncFolderSettingsClick(0, emptyCustomFolder) + } else { + PermissionUtil.requestStoragePermissionIfNeeded(this) + } + result = super.onOptionsItemSelected(item) + } + + else -> result = super.onOptionsItemSelected(item) + } + return result + } + + override fun onSyncFolderSettingsClick(section: Int, syncedFolderDisplayItem: SyncedFolderDisplayItem?) { + check(Looper.getMainLooper().isCurrentThread) { "This must be called on the main thread!" } + + dialogFragment = SyncedFolderPreferencesDialogFragment.newInstance( + syncedFolderDisplayItem, + section + ) + + dialogFragment?.let { folderPreferencesDialog -> + if (isDialogFragmentReady(folderPreferencesDialog) && + lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + ) { + val fragmentTransaction = supportFragmentManager + .beginTransaction() + .addToBackStack(null) + + folderPreferencesDialog.show(fragmentTransaction, SYNCED_FOLDER_PREFERENCES_DIALOG_TAG) + } else { + Log_OC.d(TAG, "SyncedFolderPreferencesDialogFragment not ready") + } + } + } + + override fun onVisibilityToggleClick(section: Int, syncedFolder: SyncedFolderDisplayItem?) { + if (syncedFolder == null) return + + syncedFolder.isHidden = !syncedFolder.isHidden + saveOrUpdateSyncedFolder(syncedFolder) + adapter.setSyncFolderItem(section, syncedFolder) + checkAndShowEmptyListContent() + } + + private fun showEmptyContent(headline: String, message: String, action: String) { + showEmptyContent(headline, message) + binding.emptyList.emptyListViewAction.text = action + binding.emptyList.emptyListViewAction.visibility = View.VISIBLE + binding.emptyList.emptyListViewText.visibility = View.GONE + } + + private fun showLoadingContent() { + binding.loadingContent.visibility = View.VISIBLE + binding.emptyList.emptyListViewAction.visibility = View.GONE + } + + private fun showEmptyContent(headline: String, message: String) { + binding.emptyList.emptyListViewAction.visibility = View.GONE + binding.emptyList.emptyListView.visibility = View.VISIBLE + binding.list.visibility = View.GONE + binding.loadingContent.visibility = View.GONE + binding.emptyList.emptyListViewHeadline.text = headline + binding.emptyList.emptyListViewText.text = message + binding.emptyList.emptyListViewText.visibility = View.VISIBLE + binding.emptyList.emptyListIcon.visibility = View.VISIBLE + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == SyncedFolderPreferencesDialogFragment.REQUEST_CODE__SELECT_REMOTE_FOLDER && + resultCode == RESULT_OK && + dialogFragment != null + ) { + val chosenFolder: OCFile? = FolderPickerActivity.EXTRA_FOLDER?.let { + data?.getParcelableArgument(it, OCFile::class.java) + } + dialogFragment?.setRemoteFolderSummary(chosenFolder?.remotePath) + } else if ( + requestCode == SyncedFolderPreferencesDialogFragment.REQUEST_CODE__SELECT_LOCAL_FOLDER && + resultCode == RESULT_OK && + dialogFragment != null + ) { + val localPath = data!!.getStringExtra(UploadFilesActivity.EXTRA_CHOSEN_FILES) + dialogFragment!!.setLocalFolderSummary(localPath) + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + override fun onSaveSyncedFolderPreference(syncedFolder: SyncedFolderParcelable?) { + if (syncedFolder == null) { + return + } + + // custom folders newly created aren't in the list already, + // so triggering a refresh + if (MediaFolderType.CUSTOM == syncedFolder.type && syncedFolder.id == SyncedFolder.UNPERSISTED_ID) { + val newCustomFolder = SyncedFolderDisplayItem( + SyncedFolder.UNPERSISTED_ID, + syncedFolder.localPath, + syncedFolder.remotePath, + syncedFolder.isWifiOnly, + syncedFolder.isChargingOnly, + syncedFolder.isExisting, + syncedFolder.isSubfolderByDate, + syncedFolder.account, + syncedFolder.uploadAction, + syncedFolder.nameCollisionPolicy.serialize(), + syncedFolder.isEnabled, + clock.currentTime, + File(syncedFolder.localPath).name, + syncedFolder.type, + syncedFolder.isHidden, + syncedFolder.subFolderRule, + syncedFolder.isExcludeHidden, + SyncedFolder.NOT_SCANNED_YET + ) + saveOrUpdateSyncedFolder(newCustomFolder) + adapter.addSyncFolderItem(newCustomFolder) + } else { + val item = adapter.get(syncedFolder.section) ?: return + updateSyncedFolderItem( + item, + syncedFolder.id, + syncedFolder.localPath, + syncedFolder.remotePath, + syncedFolder.isWifiOnly, + syncedFolder.isChargingOnly, + syncedFolder.isExisting, + syncedFolder.isSubfolderByDate, + syncedFolder.uploadAction, + syncedFolder.nameCollisionPolicy.serialize(), + syncedFolder.isEnabled, + syncedFolder.subFolderRule, + syncedFolder.isExcludeHidden + ) + saveOrUpdateSyncedFolder(item) + + adapter.notifyItemChanged(adapter.getSectionHeaderIndex(syncedFolder.section)) + } + dialogFragment = null + if (syncedFolder.isEnabled) { + showBatteryOptimizationDialogIfNeeded() + } + } + + override fun showSubFolderWarningDialog() { + val dialog = ConfirmationDialogFragment.newInstance( + messageResId = R.string.auto_upload_sub_folder_warning, + messageArguments = null, + titleResId = R.string.sync_duplication, + titleIconId = R.drawable.ic_info, + positiveButtonTextId = R.string.dialog_close, + negativeButtonTextId = -1, + neutralButtonTextId = -1 + ) + + if (isDialogFragmentReady(dialog)) { + dialog.show(supportFragmentManager, SUB_FOLDER_WARNING_DIALOG_TAG) + } + } + + private fun saveOrUpdateSyncedFolder(item: SyncedFolderDisplayItem) { + if (item.id == SyncedFolder.UNPERSISTED_ID) { + // newly set up folder sync config + storeSyncedFolder(item) + } else { + // existing synced folder setup to be updated + syncedFolderProvider.updateSyncFolder(item) + if (item.isEnabled) { + backgroundJobManager.startAutoUpload(item, overridePowerSaving = false) + } else { + val syncedFolderInitiatedKey = KEY_SYNCED_FOLDER_INITIATED_PREFIX + item.id + val arbitraryDataProvider = + ArbitraryDataProviderImpl(MainApp.getAppContext()) + arbitraryDataProvider.deleteKeyForAccount("global", syncedFolderInitiatedKey) + } + } + } + + private fun storeSyncedFolder(item: SyncedFolderDisplayItem) { + val arbitraryDataProvider = + ArbitraryDataProviderImpl(MainApp.getAppContext()) + val storedId = syncedFolderProvider.storeSyncedFolder(item) + if (storedId != -1L) { + item.id = storedId + if (item.isEnabled) { + backgroundJobManager.startAutoUpload(item, overridePowerSaving = false) + } else { + val syncedFolderInitiatedKey = KEY_SYNCED_FOLDER_INITIATED_PREFIX + item.id + arbitraryDataProvider.deleteKeyForAccount("global", syncedFolderInitiatedKey) + } + } + } + + override fun onCancelSyncedFolderPreference() { + dialogFragment = null + } + + override fun onSyncStatusToggleClick(section: Int, item: SyncedFolderDisplayItem?) { + item ?: return + + // Ensure the item is persisted + if (item.id <= SyncedFolder.UNPERSISTED_ID) { + syncedFolderProvider.storeSyncedFolder(item) + .takeIf { it != -1L } + ?.let { item.id = it } + } else { + syncedFolderProvider.updateSyncedFolderEnabled(item.id, item.isEnabled) + } + + if (item.isEnabled) { + Log_OC.d(TAG, "auto-upload configuration sync status is enabled: " + item.remotePath) + backgroundJobManager.startAutoUpload(item, overridePowerSaving = false) + showBatteryOptimizationDialogIfNeeded() + return + } + + Log_OC.d(TAG, "auto-upload configuration sync status is disabled: " + item.remotePath) + + lifecycleScope.launch(Dispatchers.IO) { + fileUploadHelper.removeEntityFromUploadEntities(item.id) + } + } + + override fun onDeleteSyncedFolderPreference(syncedFolder: SyncedFolderParcelable?) { + if (syncedFolder == null) { + return + } + + Log_OC.d(TAG, "deleting auto upload configuration: " + syncedFolder.remotePath) + + lifecycleScope.launch(Dispatchers.IO) { + fileUploadHelper.removeEntityFromUploadEntities(syncedFolder.id) + syncedFolderProvider.deleteSyncedFolder(syncedFolder.id) + withContext(Dispatchers.Main) { + adapter.removeItem(syncedFolder.section) + } + } + } + + /** + * update given synced folder with the given values. + * + * @param item the synced folder to be updated + * @param localPath the local path + * @param remotePath the remote path + * @param wifiOnly upload on wifi only + * @param chargingOnly upload on charging only + * @param existing also upload existing + * @param subfolderByDate created sub folders + * @param uploadAction upload action + * @param nameCollisionPolicy what to do on name collision + * @param enabled is sync enabled + * @param excludeHidden exclude hidden file or folder, for {@link MediaFolderType#CUSTOM} only + */ + @Suppress("LongParameterList") + private fun updateSyncedFolderItem( + item: SyncedFolderDisplayItem, + id: Long, + localPath: String, + remotePath: String, + wifiOnly: Boolean, + chargingOnly: Boolean, + existing: Boolean, + subfolderByDate: Boolean, + uploadAction: Int, + nameCollisionPolicy: Int, + enabled: Boolean, + subFolderRule: SubFolderRule, + excludeHidden: Boolean + ) { + item.id = id + item.localPath = localPath + item.remotePath = remotePath + item.isWifiOnly = wifiOnly + item.isChargingOnly = chargingOnly + item.isExisting = existing + item.isSubfolderByDate = subfolderByDate + item.uploadAction = uploadAction + item.setNameCollisionPolicy(nameCollisionPolicy) + item.setEnabled(enabled, clock.currentTime) + item.setSubFolderRule(subFolderRule) + item.setExcludeHidden(excludeHidden) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + when (requestCode) { + PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE -> { + // If request is cancelled, result arrays are empty. + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // permission was granted + load(getItemsDisplayedPerFolder(), true) + } + } + + else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + } + + private fun showBatteryOptimizationDialogIfNeeded() { + if (!BatteryOptimizationHelper.isBatteryOptimizationEnabled(this)) { + Log_OC.d(TAG, "battery optimization is disabled") + return + } + + showBatteryOptimizationDialog() + } + + private fun showBatteryOptimizationDialog() { + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + Log_OC.w(TAG, "Activity not resumed, skipping battery dialog") + return + } + + val dialog = MaterialAlertDialogBuilder(this, R.style.Theme_ownCloud_Dialog) + .setTitle(R.string.battery_optimization_title) + .setMessage(R.string.battery_optimization_message) + .setPositiveButton(R.string.battery_optimization_disable) { _, _ -> + BatteryOptimizationHelper.openBatteryOptimizationSettings(this) + } + .setNeutralButton(R.string.battery_optimization_close, null) + .setIcon(R.drawable.ic_battery_alert) + + val alertDialog = dialog.show() + + viewThemeUtils.platform.colorTextButtons( + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE), + alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL) + ) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt b/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt new file mode 100644 index 000000000000..a0ae72f6ba47 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt @@ -0,0 +1,80 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity + +import android.annotation.SuppressLint +import androidx.core.net.toUri +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import com.nextcloud.android.common.ui.util.PlatformThemeUtil +import com.nextcloud.client.appinfo.AppInfo +import com.nextcloud.client.device.DeviceInfo +import com.nextcloud.utils.EditorUtils +import com.owncloud.android.R +import com.owncloud.android.ui.asynctasks.TextEditorLoadUrlTask +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ThemeUtils +import javax.inject.Inject + +class TextEditorWebView : EditorWebView() { + @Inject + lateinit var appInfo: AppInfo + + @Inject + lateinit var deviceInfo: DeviceInfo + + @Inject + lateinit var themeUtils: ThemeUtils + + @Inject + lateinit var editorUtils: EditorUtils + + @SuppressLint("AddJavascriptInterface") // suppress warning as webview is only used > Lollipop + override fun postOnCreate() { + super.postOnCreate() + + if (!user.isPresent) { + DisplayUtils.showSnackMessage(this, R.string.failed_to_start_editor) + finish() + } + + val editor = editorUtils.getEditor(user.get(), file?.mimeType) + + if (editor != null && editor.id == "onlyoffice") { + webView.settings.userAgentString = generateOnlyOfficeUserAgent() + } + + webView.addJavascriptInterface(MobileInterface(), "DirectEditingMobileInterface") + + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { + WebSettingsCompat.setForceDarkStrategy( + webView.settings, + WebSettingsCompat.DARK_STRATEGY_WEB_THEME_DARKENING_ONLY + ) + } + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK) && PlatformThemeUtil.isDarkMode(this)) { + WebSettingsCompat.setForceDark(webView.settings, WebSettingsCompat.FORCE_DARK_ON) + } + + webView.setDownloadListener { url, _, _, _, _ -> downloadFile(url.toUri(), fileName) } + + loadUrl(intent.getStringExtra(EXTRA_URL)) + } + + override fun loadUrl(url: String?) { + if (url.isNullOrEmpty()) { + TextEditorLoadUrlTask(this, user.get(), file, editorUtils).execute() + } + } + + private fun generateOnlyOfficeUserAgent(): String { + val userAgent = applicationContext.resources.getString(R.string.only_office_user_agent) + + return String.format(userAgent, deviceInfo.androidVersion, appInfo.getAppVersion(this)) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java new file mode 100644 index 000000000000..7db6c88e12bb --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java @@ -0,0 +1,426 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 ZetaTom + * SPDX-FileCopyrightText: 2023 Parneet Singh + * SPDX-FileCopyrightText: 2022 Brey Álvaro Brey + * SPDX-FileCopyrightText: 2022 TSI-mc + * SPDX-FileCopyrightText: 2020 Joris Bodin + * SPDX-FileCopyrightText: 2016-2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2018-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016 Nextcloud + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.activity; + +import android.animation.AnimatorInflater; +import android.annotation.SuppressLint; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.textview.MaterialTextView; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.nextcloud.client.di.Injectable; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.OCFileDepth; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.fragment.OCFileListFragment; +import com.owncloud.android.ui.fragment.SearchType; +import com.owncloud.android.utils.theme.ThemeColorUtils; +import com.owncloud.android.utils.theme.ThemeUtils; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.AppCompatSpinner; +import androidx.core.content.ContextCompat; + +/** + * Base class providing toolbar registration functionality, see {@link #setupToolbar(boolean, boolean)}. + */ +public abstract class ToolbarActivity extends BaseActivity implements Injectable { + protected MaterialButton mMenuButton; + protected MaterialTextView mSearchText; + protected MaterialButton mSwitchAccountButton; + protected MaterialButton mNotificationButton; + + private AppBarLayout mAppBar; + private RelativeLayout mDefaultToolbar; + private MaterialToolbar mToolbar; + private MaterialCardView mHomeSearchContainer; + private LinearLayout mHomeSearchToolbar; + private ImageView mPreviewImage; + private FrameLayout mPreviewImageContainer; + private LinearLayout mInfoBox; + private TextView mInfoBoxMessage; + protected AppCompatSpinner mToolbarSpinner; + private boolean isHomeSearchToolbarShow = false; + private static final String TAG = "ToolbarActivity"; + + @Inject public ThemeColorUtils themeColorUtils; + @Inject public ThemeUtils themeUtils; + @Inject public ViewThemeUtils viewThemeUtils; + + /** + * Toolbar setup that must be called in implementer's {@link #onCreate} after {@link #setContentView} if they want + * to use the toolbar. + */ + private void setupToolbar(boolean isHomeSearchToolbarShow, boolean showSortListButtonGroup) { + mToolbar = findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + + mAppBar = findViewById(R.id.appbar); + mDefaultToolbar = findViewById(R.id.default_toolbar); + mHomeSearchToolbar = findViewById(R.id.home_toolbar); + mHomeSearchContainer = findViewById(R.id.home_search_container); + mMenuButton = findViewById(R.id.menu_button); + mSearchText = findViewById(R.id.search_text); + mSwitchAccountButton = findViewById(R.id.switch_account_button); + mNotificationButton = findViewById(R.id.notification_button); + + if (showSortListButtonGroup) { + findViewById(R.id.sort_list_button_group).setVisibility(View.VISIBLE); + } + + this.isHomeSearchToolbarShow = isHomeSearchToolbarShow; + updateActionBarTitleAndHomeButton(null); + + mInfoBox = findViewById(R.id.info_box); + mInfoBoxMessage = findViewById(R.id.info_box_message); + + mPreviewImage = findViewById(R.id.preview_image); + mPreviewImageContainer = findViewById(R.id.preview_image_frame); + + mToolbarSpinner = findViewById(R.id.toolbar_spinner); + + viewThemeUtils.material.themeToolbar(mToolbar); + viewThemeUtils.material.colorToolbarOverflowIcon(mToolbar); + viewThemeUtils.platform.themeStatusBar(this); + viewThemeUtils.material.colorMaterialTextButton(mSwitchAccountButton); + + viewThemeUtils.material.themeSearchCardView(mHomeSearchContainer); + viewThemeUtils.material.colorMaterialButtonContent(mMenuButton, ColorRole.ON_SURFACE); + viewThemeUtils.material.colorMaterialButtonContent(mNotificationButton, ColorRole.ON_SURFACE); + viewThemeUtils.platform.colorTextView(mSearchText, ColorRole.ON_SURFACE_VARIANT); + } + + public void setupToolbarShowOnlyMenuButtonAndTitle(String title, View.OnClickListener toggleDrawer) { + setupToolbar(false, false); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayShowTitleEnabled(false); + } + + LinearLayout toolbar = findViewById(R.id.toolbar_linear_layout); + MaterialButton menuButton = findViewById(R.id.toolbar_menu_button); + MaterialTextView titleTextView = findViewById(R.id.toolbar_title); + titleTextView.setText(title); + + titleTextView.setTextColor(ContextCompat.getColor(this, R.color.foreground_highlight)); + menuButton.setIconTint(ContextCompat.getColorStateList(this, R.color.foreground_highlight)); + + toolbar.setVisibility(View.VISIBLE); + menuButton.setOnClickListener(toggleDrawer); + } + + /** + * Shows plain action bar + */ + public void setupToolbar() { + if (mHomeSearchToolbar != null && mDefaultToolbar != null && mHomeSearchToolbar.getVisibility() == View.GONE && mDefaultToolbar.getVisibility() == View.VISIBLE) { + Log_OC.d(TAG, "Search toolbar is already hidden, skipping update."); + return; + } + + setupToolbar(false, false); + } + + /** + * Shows action bar with search + */ + public void setupHomeSearchToolbarWithSortAndListButtons() { + if (mHomeSearchToolbar != null && mDefaultToolbar != null && mHomeSearchToolbar.getVisibility() == View.VISIBLE && mDefaultToolbar.getVisibility() == View.GONE) { + Log_OC.d(TAG, "Search toolbar is already visible, skipping update."); + return; + } + + setupToolbar(true, true); + } + + private OCFileListFragment getOCFileListFragment() { + if (this instanceof FileDisplayActivity fda) { + return fda.getListOfFilesFragment(); + } + + return null; + } + + private OCFileDepth getCurrentDirDepth() { + OCFileListFragment fragment = getOCFileListFragment(); + if (fragment != null) { + return fragment.getFileDepth(); + } + + return null; + } + + private SearchType getSearchType() { + final OCFileListFragment fragment = getOCFileListFragment(); + if (fragment != null) { + return fragment.getCurrentSearchType(); + } + return SearchType.NO_SEARCH; + } + + public String getActionBarRootTitle() { + final SearchType searchType = getSearchType(); + Integer rootTitleId = searchType.titleId(); + String result = themeUtils.getDefaultDisplayNameForRootFolder(this); + + if (rootTitleId != null) { + result = getString(rootTitleId); + } + + return result; + } + + public String getActionBarTitle(OCFile chosenFile, boolean isRoot) { + if (isRoot) { + return getActionBarRootTitle(); + } + + return getActionBarTitleFromFile(chosenFile); + } + + private String getActionBarTitleFromFile(OCFile file) { + // if offline rename operation already pointing same file, offline operation name will be used + return fileDataStorageManager.getFilenameConsideringOfflineOperation(file); + } + + protected void updateActionBarTitleAndHomeButton(OCFile file) { + final OCFileDepth currentDirDepth = getCurrentDirDepth(); + final boolean isRoot = isRoot(file) || currentDirDepth == OCFileDepth.Root; + final String title = getActionBarTitle(file, isRoot); + updateActionBarTitleAndHomeButtonByString(title); + + boolean isToolbarStyleSearch = false; + if (this instanceof DrawerActivity drawerActivity) { + isToolbarStyleSearch = drawerActivity.isToolbarStyleSearch(); + } + final boolean canShowSearchBar = (isHomeSearchToolbarShow && isRoot && isToolbarStyleSearch); + + showHomeSearchToolbar(canShowSearchBar); + + if (mSearchText != null) { + mSearchText.setText(getString(R.string.appbar_search_in, title)); + } + + final var actionBar = getSupportActionBar(); + if (actionBar != null) { + viewThemeUtils.files.themeActionBar(this, actionBar, title, isRoot); + } + } + + protected void updateActionBarForFile(@Nullable OCFile file) { + if (file == null) { + return; + } + + final String title = getActionBarTitleFromFile(file); + updateActionBarTitleAndHomeButtonByString(title); + + showHomeSearchToolbar(false); + final var actionBar = getSupportActionBar(); + if (actionBar != null) { + viewThemeUtils.files.themeActionBar(this, actionBar, title, false); + } + } + + public void showSearchView() { + if (isHomeSearchToolbarShow) { + showHomeSearchToolbar(false); + } + } + + public void hideSearchView(OCFile chosenFile) { + if (isHomeSearchToolbarShow) { + showHomeSearchToolbar(isRoot(chosenFile)); + } + } + + @SuppressLint("PrivateResource") + private void showHomeSearchToolbar(boolean isShow) { + if (mAppBar == null) { + return; + } + + viewThemeUtils.material.themeToolbar(mToolbar); + + if (isShow) { + viewThemeUtils.platform.resetStatusBar(this); + mAppBar.setStateListAnimator(AnimatorInflater.loadStateListAnimator(mAppBar.getContext(), + R.animator.appbar_elevation_off)); + mDefaultToolbar.setVisibility(View.GONE); + mHomeSearchToolbar.setVisibility(View.VISIBLE); + viewThemeUtils.material.themeSearchCardView(mHomeSearchContainer); + viewThemeUtils.material.themeSearchBarText(mSearchText); + } else { + mAppBar.setStateListAnimator(AnimatorInflater.loadStateListAnimator(mAppBar.getContext(), + R.animator.appbar_elevation_on)); + viewThemeUtils.platform.themeStatusBar(this); + mDefaultToolbar.setVisibility(View.VISIBLE); + mHomeSearchToolbar.setVisibility(View.GONE); + } + } + + /** + * Updates title bar and home buttons (state and icon). + */ + public void updateActionBarTitleAndHomeButtonByString(String title) { + // set & color the chosen title + ActionBar actionBar = getSupportActionBar(); + + // set home button properties + if (actionBar != null) { + if (title != null) { + actionBar.setTitle(title); + actionBar.setDisplayShowTitleEnabled(true); + } else { + actionBar.setDisplayShowTitleEnabled(false); + } + } + } + + /** + * checks if the given file is the root folder. + * + * @param file file to be checked if it is the root folder + * @return true if it is null or the root folder, else returns false + */ + public boolean isRoot(OCFile file) { + + return file == null || (file.isFolder() && file.getParentId() == FileDataStorageManager.ROOT_PARENT_ID); + } + + /** + * shows the toolbar's info box with the given text. + * + * @param text the text to be displayed + */ + protected final void showInfoBox(@StringRes int text) { + if (mInfoBox != null && mInfoBoxMessage != null) { + mInfoBox.setVisibility(View.VISIBLE); + mInfoBoxMessage.setText(text); + } + } + + /** + * Hides the toolbar's info box. + */ + public final void hideInfoBox() { + if (mInfoBox != null) { + mInfoBox.setVisibility(View.GONE); + } + } + + public void setPreviewImageVisibility(boolean isVisibility) { + if (mPreviewImage != null && mPreviewImageContainer != null) { + if (isVisibility) { + mToolbar.setTitle(null); + mToolbar.setBackgroundColor(Color.TRANSPARENT); + } else { + mToolbar.setBackgroundResource(R.color.appbar); + } + mPreviewImageContainer.setVisibility(isVisibility ? View.VISIBLE : View.GONE); + } + } + + public void hidePreviewImage() { + setPreviewImageVisibility(false); + } + + public void showSortListGroup(boolean show) { + final var view = findViewById(R.id.sort_list_button_group); + if (view == null) { + return; + } + + view.setVisibility(show ? View.VISIBLE : View.GONE); + } + + public boolean sortListGroupVisibility(){ + final var view = findViewById(R.id.sort_list_button_group); + if (view == null) { + return false; + } + + return view.getVisibility() == View.VISIBLE; + } + /** + * Change the bitmap for the toolbar's preview image. + * + * @param bitmap bitmap of the preview image + */ + public void setPreviewImageBitmap(Bitmap bitmap) { + if (mPreviewImage != null) { + mPreviewImage.setImageBitmap(bitmap); + setPreviewImageVisibility(true); + } + } + + /** + * Change the drawable for the toolbar's preview image. + * + * @param drawable drawable of the preview image + */ + public void setPreviewImageDrawable(Drawable drawable) { + if (mPreviewImage != null) { + mPreviewImage.setImageDrawable(drawable); + setPreviewImageVisibility(true); + } + } + + /** + * get the toolbar's preview image view. + */ + public ImageView getPreviewImageView() { + return mPreviewImage; + } + + public FrameLayout getPreviewImageContainer() { + return mPreviewImageContainer; + } + + public void updateToolbarSubtitle(@NonNull String subtitle) { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setSubtitle(subtitle); + viewThemeUtils.androidx.themeActionBarSubtitle(this, actionBar); + } + } + + public void clearToolbarSubtitle() { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setSubtitle(null); + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java new file mode 100644 index 000000000000..5dab3516d444 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java @@ -0,0 +1,801 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2020-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Joris Bodin + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2012 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.activity; + +import android.accounts.Account; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.os.Environment; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.nextcloud.client.account.User; +import com.nextcloud.client.core.Clock; +import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.utils.extensions.ActivityExtensionsKt; +import com.nextcloud.utils.extensions.FileExtensionsKt; +import com.nextcloud.utils.extensions.SyncedFolderExtensionsKt; +import com.owncloud.android.R; +import com.owncloud.android.databinding.UploadFilesLayoutBinding; +import com.owncloud.android.datamodel.SyncedFolderProvider; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.adapter.StoragePathAdapter; +import com.owncloud.android.ui.asynctasks.CheckAvailableSpaceTask; +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment; +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener; +import com.owncloud.android.ui.dialog.IndeterminateProgressDialog; +import com.owncloud.android.ui.dialog.LocalStoragePathPickerDialogFragment; +import com.owncloud.android.ui.dialog.SortingOrderDialogFragment; +import com.owncloud.android.ui.fragment.ExtendedListFragment; +import com.owncloud.android.ui.fragment.LocalFileListFragment; +import com.owncloud.android.utils.FileSortOrder; +import com.owncloud.android.utils.FileUtil; +import com.owncloud.android.utils.PermissionUtil; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.SearchView; +import androidx.core.view.MenuItemCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import kotlin.Unit; +import kotlin.jvm.functions.Function1; + +import static com.owncloud.android.ui.activity.FileActivity.EXTRA_USER; + +/** + * Displays local files and let the user choose what of them wants to upload to the current Nextcloud account. + */ +public class UploadFilesActivity extends DrawerActivity implements LocalFileListFragment.ContainerActivity, + OnClickListener, ConfirmationDialogFragmentListener, SortingOrderDialogFragment.OnSortingOrderListener, + CheckAvailableSpaceTask.CheckAvailableSpaceListener, StoragePathAdapter.StoragePathAdapterListener, Injectable { + + private static final String KEY_ALL_SELECTED = UploadFilesActivity.class.getCanonicalName() + ".KEY_ALL_SELECTED"; + public final static String KEY_LOCAL_FOLDER_PICKER_MODE = UploadFilesActivity.class.getCanonicalName() + ".LOCAL_FOLDER_PICKER_MODE"; + public static final String LOCAL_BASE_PATH = UploadFilesActivity.class.getCanonicalName() + ".LOCAL_BASE_PATH"; + public static final String EXTRA_CHOSEN_FILES = UploadFilesActivity.class.getCanonicalName() + ".EXTRA_CHOSEN_FILES"; + public static final String KEY_DIRECTORY_PATH = UploadFilesActivity.class.getCanonicalName() + ".KEY_DIRECTORY_PATH"; + + private static final int SINGLE_DIR = 1; + public static final int RESULT_OK_AND_DELETE = 3; + public static final int RESULT_OK_AND_DO_NOTHING = 2; + public static final int RESULT_OK_AND_MOVE = RESULT_FIRST_USER; + public static final String REQUEST_CODE_KEY = "requestCode"; + private static final String ENCRYPTED_FOLDER_KEY = "encrypted_folder"; + + private static final String QUERY_TO_MOVE_DIALOG_TAG = "QUERY_TO_MOVE"; + private static final String SUB_FOLDER_WARNING_DIALOG_TAG = "SUB_FOLDER_WARNING_DIALOG"; + private static final String TAG = "UploadFilesActivity"; + private static final String WAIT_DIALOG_TAG = "WAIT"; + + @Inject AppPreferences preferences; + + @Inject + Clock clock; + + private Account mAccountOnCreation; + private ArrayAdapter mDirectories; + private boolean mLocalFolderPickerMode; + private boolean mSelectAll; + private DialogFragment mCurrentDialog; + private File mCurrentDir; + private int requestCode; + private LocalFileListFragment mFileListFragment; + private LocalStoragePathPickerDialogFragment dialog; + private Menu mOptionsMenu; + private SearchView mSearchView; + private UploadFilesLayoutBinding binding; + private boolean isWithinEncryptedFolder = false; + + public LocalFileListFragment getFileListFragment() { + return mFileListFragment; + } + + /** + * Helper to launch the UploadFilesActivity for which you would like a result when it finished. Your + * onActivityResult() method will be called with the given requestCode. + * + * @param activity the activity which should call the upload activity for a result + * @param user the user for which the upload activity is called + * @param requestCode If >= 0, this code will be returned in onActivityResult() + */ + public static void startUploadActivityForResult(Activity activity, + User user, + int requestCode, + boolean isWithinEncryptedFolder) { + Intent action = new Intent(activity, UploadFilesActivity.class); + action.putExtra(EXTRA_USER, user); + action.putExtra(REQUEST_CODE_KEY, requestCode); + action.putExtra(ENCRYPTED_FOLDER_KEY, isWithinEncryptedFolder); + activity.startActivityForResult(action, requestCode); + } + + @Override + @SuppressLint("WrongViewCast") // wrong error on finding local_files_list + public void onCreate(Bundle savedInstanceState) { + Log_OC.d(TAG, "onCreate() start"); + super.onCreate(savedInstanceState); + + Bundle extras = getIntent().getExtras(); + if (extras != null) { + mLocalFolderPickerMode = extras.getBoolean(KEY_LOCAL_FOLDER_PICKER_MODE, false); + requestCode = (int) extras.get(REQUEST_CODE_KEY); + isWithinEncryptedFolder = extras.getBoolean(ENCRYPTED_FOLDER_KEY, false); + } + + if (savedInstanceState != null) { + mCurrentDir = new File(savedInstanceState.getString(KEY_DIRECTORY_PATH, + Environment.getExternalStorageDirectory().getAbsolutePath())); + mSelectAll = savedInstanceState.getBoolean(KEY_ALL_SELECTED, false); + isWithinEncryptedFolder = savedInstanceState.getBoolean(ENCRYPTED_FOLDER_KEY, false); + } else { + String lastUploadFrom = preferences.getUploadFromLocalLastPath(); + + if (!lastUploadFrom.isEmpty()) { + mCurrentDir = new File(lastUploadFrom); + + while (!mCurrentDir.exists()) { + mCurrentDir = mCurrentDir.getParentFile(); + } + } else { + mCurrentDir = Environment.getExternalStorageDirectory(); + } + } + + mAccountOnCreation = getAccount(); + + /// USER INTERFACE + + // Drop-down navigation + mDirectories = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item); + mDirectories.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + fillDirectoryDropdown(); + + // Inflate and set the layout view + binding = UploadFilesLayoutBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + if (mLocalFolderPickerMode) { + binding.uploadOptions.setVisibility(View.GONE); + binding.uploadFilesBtnUpload.setText(R.string.uploader_btn_alternative_text); + } + + mFileListFragment = (LocalFileListFragment) getSupportFragmentManager().findFragmentByTag("local_files_list"); + + // Set input controllers + viewThemeUtils.material.colorMaterialButtonPrimaryOutlined(binding.uploadFilesBtnCancel); + binding.uploadFilesBtnCancel.setOnClickListener(this); + + viewThemeUtils.material.colorMaterialButtonPrimaryFilled(binding.uploadFilesBtnUpload); + binding.uploadFilesBtnUpload.setOnClickListener(this); + binding.uploadFilesBtnUpload.setEnabled(mLocalFolderPickerMode); + + int localBehaviour = preferences.getUploaderBehaviour(); + + // file upload spinner + List behaviours = new ArrayList<>(); + behaviours.add(getString(R.string.uploader_upload_files_behaviour_move_to_nextcloud_folder, + themeUtils.getDefaultDisplayNameForRootFolder(this))); + behaviours.add(getString(R.string.uploader_upload_files_behaviour_only_upload)); + behaviours.add(getString(R.string.uploader_upload_files_behaviour_upload_and_delete_from_source)); + + ArrayAdapter behaviourAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, + behaviours); + behaviourAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.uploadFilesSpinnerBehaviour.setAdapter(behaviourAdapter); + binding.uploadFilesSpinnerBehaviour.setSelection(localBehaviour); + + // setup the toolbar + setupToolbar(); + binding.uploadFilesToolbar.sortListButtonGroup.setVisibility(View.VISIBLE); + binding.uploadFilesToolbar.switchGridViewButton.setVisibility(View.GONE); + + // Action bar setup + ActionBar actionBar = getSupportActionBar(); + + if (actionBar != null) { + actionBar.setHomeButtonEnabled(true); // mandatory since Android ICS, according to the official documentation + actionBar.setDisplayHomeAsUpEnabled(mCurrentDir != null); + actionBar.setDisplayShowTitleEnabled(false); + + viewThemeUtils.files.themeActionBar(this, actionBar); + } + + showToolbarSpinner(); + mToolbarSpinner.setAdapter(mDirectories); + mToolbarSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + int i = position; + while (i-- != 0) { + getOnBackPressedDispatcher().onBackPressed(); + } + // the next operation triggers a new call to this method, but it's necessary to + // ensure that the name exposed in the action bar is the current directory when the + // user selected it in the navigation list + if (position != 0) { + mToolbarSpinner.setSelection(0); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + // no action + } + }); + + // wait dialog + if (mCurrentDialog != null) { + mCurrentDialog.dismiss(); + mCurrentDialog = null; + } + + checkWritableFolder(mCurrentDir); + + getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); + + Log_OC.d(TAG, "onCreate() end"); + } + + public void showToolbarSpinner() { + mToolbarSpinner.setVisibility(View.VISIBLE); + } + + private void fillDirectoryDropdown() { + File currentDir = mCurrentDir; + while (currentDir != null && currentDir.getParentFile() != null) { + mDirectories.add(currentDir.getName()); + currentDir = currentDir.getParentFile(); + } + mDirectories.add(File.separator); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + mOptionsMenu = menu; + getMenuInflater().inflate(R.menu.activity_upload_files, menu); + + if (!mLocalFolderPickerMode) { + MenuItem selectAll = menu.findItem(R.id.action_select_all); + setSelectAllMenuItem(selectAll, mSelectAll); + } + + final MenuItem item = menu.findItem(R.id.action_search); + mSearchView = (SearchView) MenuItemCompat.getActionView(item); + viewThemeUtils.androidx.themeToolbarSearchView(mSearchView); + + mSearchView.setOnSearchClickListener(v -> mToolbarSpinner.setVisibility(View.GONE)); + + MenuItem chooseStoragePathItem = menu.findItem(R.id.action_choose_storage_path); + if (chooseStoragePathItem != null) { + chooseStoragePathItem.setVisible(PermissionUtil.checkStoragePermission(this)); + + final var chooseStoragePathDrawable = chooseStoragePathItem.getIcon(); + if (chooseStoragePathDrawable != null) { + viewThemeUtils.platform.tintDrawable(this, chooseStoragePathDrawable, ColorRole.ON_SURFACE); + } + } + + return super.onCreateOptionsMenu(menu); + } + + private static final String rootDir = "/storage/emulated/0"; + + private boolean isRoot() { + if (mCurrentDir == null) { + return false; + } + + return mCurrentDir.getAbsolutePath().equals(rootDir); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + + if (itemId == android.R.id.home) { + handleHomePressed(); + return true; + } + + if (itemId == R.id.action_select_all) { + mSelectAll = !item.isChecked(); + item.setChecked(mSelectAll); + mFileListFragment.selectAllFiles(mSelectAll); + setSelectAllMenuItem(item, mSelectAll); + return true; + } + + if (itemId == R.id.action_choose_storage_path) { + showLocalStoragePathPickerDialog(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void cancelAndFinish() { + setResult(RESULT_CANCELED); + finish(); + } + + private void handleHomePressed() { + boolean root = isRoot(); + boolean hasPermission = PermissionUtil.checkStoragePermission(this); + + if (root && !hasPermission) { + cancelAndFinish(); + return; + } + + if (mCurrentDir == null || mCurrentDir.getParentFile() == null) { + return; + } + + if (root) { + cancelAndFinish(); + } else { + getOnBackPressedDispatcher().onBackPressed(); + } + } + + private void showLocalStoragePathPickerDialog() { + if (!PermissionUtil.checkStoragePermission(this)) { + cancelAndFinish(); + return; + } + + FragmentManager fm = getSupportFragmentManager(); + FragmentTransaction ft = fm.beginTransaction(); + ft.addToBackStack(null); + dialog = LocalStoragePathPickerDialogFragment.newInstance(); + dialog.show(ft, LocalStoragePathPickerDialogFragment.LOCAL_STORAGE_PATH_PICKER_FRAGMENT); + } + + @Override + public void onSortingOrderChosen(FileSortOrder selection) { + preferences.setSortOrder(FileSortOrder.Type.localFileListView, selection); + mFileListFragment.sortFiles(selection); + } + + private boolean isSearchOpen() { + if (mSearchView == null) { + return false; + } else { + View mSearchEditFrame = mSearchView.findViewById(androidx.appcompat.R.id.search_edit_frame); + return mSearchEditFrame != null && mSearchEditFrame.getVisibility() == View.VISIBLE; + } + } + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (isSearchOpen() && mSearchView != null) { + mSearchView.setQuery("", false); + mFileListFragment.onClose(); + mSearchView.onActionViewCollapsed(); + setDrawerIndicatorEnabled(isDrawerIndicatorAvailable()); + } else { + if (mDirectories.getCount() <= SINGLE_DIR) { + finish(); + return; + } + + File parentFolder = mCurrentDir.getParentFile(); + if (parentFolder != null && !parentFolder.canRead()) { + return; + } + + popDirname(); + mFileListFragment.onNavigateUp(); + mCurrentDir = mFileListFragment.getCurrentDirectory(); + checkWritableFolder(mCurrentDir); + + if (mCurrentDir.getParentFile() == null) { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(false); + } + } + + // invalidate checked state when navigating directories + if (!mLocalFolderPickerMode) { + setSelectAllMenuItem(mOptionsMenu.findItem(R.id.action_select_all), false); + } + } + } + }; + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + // responsibility of restore is preferred in onCreate() before than in + // onRestoreInstanceState when there are Fragments involved + FileExtensionsKt.logFileSize(mCurrentDir, TAG); + super.onSaveInstanceState(outState); + outState.putString(UploadFilesActivity.KEY_DIRECTORY_PATH, mCurrentDir.getAbsolutePath()); + if (mOptionsMenu != null && mOptionsMenu.findItem(R.id.action_select_all) != null) { + outState.putBoolean(UploadFilesActivity.KEY_ALL_SELECTED, mOptionsMenu.findItem(R.id.action_select_all).isChecked()); + } else { + outState.putBoolean(UploadFilesActivity.KEY_ALL_SELECTED, false); + } + Log_OC.d(TAG, "onSaveInstanceState() end"); + } + + /** + * Pushes a directory to the drop down list + * + * @param directory to push + * @throws IllegalArgumentException If the {@link File#isDirectory()} returns false. + */ + public void pushDirname(File directory) { + if (!directory.isDirectory()) { + throw new IllegalArgumentException("Only directories may be pushed!"); + } + mDirectories.insert(directory.getName(), 0); + mCurrentDir = directory; + checkWritableFolder(mCurrentDir); + } + + /** + * Pops a directory name from the drop down list + */ + public void popDirname() { + mDirectories.remove(mDirectories.getItem(0)); + } + + private void updateUploadButtonActive() { + final boolean anySelected = mFileListFragment.getCheckedFilesCount() > 0; + binding.uploadFilesBtnUpload.setEnabled(anySelected || mLocalFolderPickerMode); + } + + private void setSelectAllMenuItem(MenuItem selectAll, boolean checked) { + if (selectAll != null) { + selectAll.setChecked(checked); + if (checked) { + selectAll.setIcon(R.drawable.ic_select_none); + } else { + selectAll.setIcon( + viewThemeUtils.platform.tintPrimaryDrawable(this, R.drawable.ic_select_all)); + } + updateUploadButtonActive(); + } + } + + @Override + public void onCheckAvailableSpaceStart() { + if (requestCode == FileDisplayActivity.REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM) { + mCurrentDialog = IndeterminateProgressDialog.newInstance(R.string.wait_a_moment, false); + mCurrentDialog.show(getSupportFragmentManager(), WAIT_DIALOG_TAG); + } + } + + /** + * Updates the activity UI after the check of space is done. If there is not space enough. shows a new dialog to + * query the user if wants to move the files instead of copy them. + * + * @param hasEnoughSpaceAvailable 'True' when there is space enough to copy all the selected files. + */ + @Override + public void onCheckAvailableSpaceFinish(boolean hasEnoughSpaceAvailable, String... filesToUpload) { + if (mCurrentDialog != null && ActivityExtensionsKt.isDialogFragmentReady(this, mCurrentDialog)) { + mCurrentDialog.dismiss(); + mCurrentDialog = null; + } + + if (hasEnoughSpaceAvailable) { + // return the list of files (success) + Intent data = new Intent(); + + if (requestCode == FileDisplayActivity.REQUEST_CODE__UPLOAD_FROM_CAMERA) { + data.putExtra(EXTRA_CHOSEN_FILES, new String[]{filesToUpload[0]}); + setResult(RESULT_OK_AND_DELETE, data); + + preferences.setUploaderBehaviour(FileUploadWorker.LOCAL_BEHAVIOUR_DELETE); + } else { + final var chosenFiles = mFileListFragment.getCheckedFilePaths(); + if (chosenFiles.length > FileUploadHelper.MAX_FILE_COUNT) { + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); + return; + } + + data.putExtra(EXTRA_CHOSEN_FILES, chosenFiles); + data.putExtra(LOCAL_BASE_PATH, mCurrentDir.getAbsolutePath()); + + // set result code + switch (binding.uploadFilesSpinnerBehaviour.getSelectedItemPosition()) { + case 0: // move to nextcloud folder + setResult(RESULT_OK_AND_MOVE, data); + break; + + case 1: // only upload + setResult(RESULT_OK_AND_DO_NOTHING, data); + break; + + case 2: // upload and delete from source + setResult(RESULT_OK_AND_DELETE, data); + break; + + default: + // do nothing + break; + } + + // store behaviour + preferences.setUploaderBehaviour(binding.uploadFilesSpinnerBehaviour.getSelectedItemPosition()); + } + + finish(); + } else { + // show a dialog to query the user if wants to move the selected files + // to the ownCloud folder instead of copying + String[] args = { getString(R.string.app_name) }; + ConfirmationDialogFragment dialog = ConfirmationDialogFragment.newInstance( + R.string.upload_query_move_foreign_files, args, 0, R.string.common_yes, R.string.common_no, -1); + dialog.setOnConfirmationListener(this); + dialog.show(getSupportFragmentManager(), QUERY_TO_MOVE_DIALOG_TAG); + } + } + + @Override + public void chosenPath(String path) { + if (getListOfFilesFragment() instanceof LocalFileListFragment) { + File file = new File(path); + ((LocalFileListFragment) getListOfFilesFragment()).listDirectory(file); + onDirectoryClick(file); + + mCurrentDir = new File(path); + mDirectories.clear(); + + fillDirectoryDropdown(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onDirectoryClick(File directory) { + if (!mLocalFolderPickerMode) { + // invalidate checked state when navigating directories + MenuItem selectAll = mOptionsMenu.findItem(R.id.action_select_all); + setSelectAllMenuItem(selectAll, false); + } + + pushDirname(directory); + ActionBar actionBar = getSupportActionBar(); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + private void checkWritableFolder(File folder) { + FileUtil.INSTANCE.isFolderWritable(folder, getLifecycle(), canWriteIntoFolder -> { + binding.uploadFilesSpinnerBehaviour.setEnabled(canWriteIntoFolder); + + TextView textView = findViewById(R.id.upload_files_upload_files_behaviour_text); + + if (canWriteIntoFolder) { + textView.setText(getString(R.string.uploader_upload_files_behaviour)); + int localBehaviour = preferences.getUploaderBehaviour(); + binding.uploadFilesSpinnerBehaviour.setSelection(localBehaviour); + } else { + binding.uploadFilesSpinnerBehaviour.setSelection(1); + textView.setText(new StringBuilder().append(getString(R.string.uploader_upload_files_behaviour)) + .append(' ') + .append(getString(R.string.uploader_upload_files_behaviour_not_writable)) + .toString()); + } + return Unit.INSTANCE; + }); + } + + /** + * {@inheritDoc} + */ + @Override + public void onFileClick(File file) { + updateUploadButtonActive(); + + boolean selectAll = mFileListFragment.getCheckedFilesCount() == mFileListFragment.getFilesCount(); + setSelectAllMenuItem(mOptionsMenu.findItem(R.id.action_select_all), selectAll); + } + + /** + * {@inheritDoc} + */ + @Override + public File getInitialDirectory() { + return mCurrentDir; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isFolderPickerMode() { + return mLocalFolderPickerMode; + } + + @Override + public boolean isWithinEncryptedFolder() { + return isWithinEncryptedFolder; + } + + private boolean isGivenLocalPathHasEnabledParent() { + if (mCurrentDir == null) { + return false; + } + + final var chosenPath = mCurrentDir.getPath(); + final var syncedFolderProvider = new SyncedFolderProvider(getContentResolver(), preferences, clock); + final var syncedFolders = syncedFolderProvider.getSyncedFolders(); + return SyncedFolderExtensionsKt.hasEnabledParent(syncedFolders, chosenPath); + } + + /** + * Performs corresponding action when user presses 'Cancel' or 'Upload' button + *

+ * TODO Make here the real request to the Upload service ; will require to receive the account and target folder + * where the upload must be done in the received intent. + */ + @Override + public void onClick(View v) { + if (v.getId() == R.id.upload_files_btn_cancel) { + cancelAndFinish(); + } else if (v.getId() == R.id.upload_files_btn_upload && PermissionUtil.checkStoragePermission(this)) { + if (mCurrentDir != null) { + preferences.setUploadFromLocalLastPath(mCurrentDir.getAbsolutePath()); + } + if (mLocalFolderPickerMode) { + Intent data = new Intent(); + if (mCurrentDir != null) { + data.putExtra(EXTRA_CHOSEN_FILES, mCurrentDir.getAbsolutePath()); + } + setResult(RESULT_OK, data); + + if (isGivenLocalPathHasEnabledParent()) { + showSubFolderWarningDialog(); + } else { + finish(); + } + } else { + final var chosenFiles = mFileListFragment.getCheckedFilePaths(); + if (chosenFiles.length > FileUploadHelper.MAX_FILE_COUNT) { + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); + return; + } + boolean isPositionZero = (binding.uploadFilesSpinnerBehaviour.getSelectedItemPosition() == 0); + new CheckAvailableSpaceTask(this, chosenFiles).execute(isPositionZero); + } + } + } + + private void showSubFolderWarningDialog() { + final var dialog = ConfirmationDialogFragment.newInstance( + R.string.auto_upload_sub_folder_warning, + null, + R.string.sync_duplication, + R.drawable.ic_info, + R.string.sync_anyway, + R.string.common_cancel, + -1); + + dialog.setOnConfirmationListener(new ConfirmationDialogFragmentListener() { + @Override + public void onConfirmation(@Nullable String callerTag) { + finish(); + } + + @Override + public void onNeutral(@Nullable String callerTag) { + + } + + @Override + public void onCancel(@Nullable String callerTag) { + + } + }); + + final var isDialogFragmentReady = ActivityExtensionsKt.isDialogFragmentReady(this, dialog); + if (isDialogFragmentReady) { + dialog.show(getSupportFragmentManager(), SUB_FOLDER_WARNING_DIALOG_TAG); + } + } + + @Override + public void onConfirmation(String callerTag) { + Log_OC.d(TAG, "Positive button in dialog was clicked; dialog tag is " + callerTag); + final var chosenFiles = mFileListFragment.getCheckedFilePaths(); + if (chosenFiles.length > FileUploadHelper.MAX_FILE_COUNT) { + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); + return; + } + + if (QUERY_TO_MOVE_DIALOG_TAG.equals(callerTag)) { + // return the list of selected files to the caller activity (success), + // signaling that they should be moved to the ownCloud folder, instead of copied + Intent data = new Intent(); + data.putExtra(EXTRA_CHOSEN_FILES, chosenFiles); + data.putExtra(LOCAL_BASE_PATH, mCurrentDir.getAbsolutePath()); + setResult(RESULT_OK_AND_MOVE, data); + finish(); + } + } + + @Override + public void onNeutral(String callerTag) { + Log_OC.d(TAG, "Phantom neutral button in dialog was clicked; dialog tag is " + callerTag); + } + + @Override + public void onCancel(String callerTag) { + /// nothing to do; don't finish, let the user change the selection + Log_OC.d(TAG, "Negative button in dialog was clicked; dialog tag is " + callerTag); + } + + @Override + protected void onStart() { + super.onStart(); + final Account account = getAccount(); + if (mAccountOnCreation == null || !mAccountOnCreation.equals(account)) { + cancelAndFinish(); + } + } + + private boolean isGridView() { + return getListOfFilesFragment().isGridEnabled(); + } + + private ExtendedListFragment getListOfFilesFragment() { + if (mFileListFragment == null) { + Log_OC.e(TAG, "Access to unexisting list of files fragment!!"); + } + + return mFileListFragment; + } + + @Override + protected void onStop() { + if (dialog != null) { + dialog.dismissAllowingStateLoss(); + } + + super.onStop(); + } + + public void setupStoragePermissionWarningBanner() { + if (getListOfFilesFragment() instanceof LocalFileListFragment fragment) { + fragment.setupStoragePermissionWarningBanner(); + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt new file mode 100755 index 000000000000..56debc1a184c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt @@ -0,0 +1,378 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.activity + +import android.accounts.Account +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.recyclerview.widget.GridLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.client.account.User +import com.nextcloud.client.core.Clock +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.utils.Throttler +import com.nextcloud.utils.extensions.webDavParentPath +import com.owncloud.android.R +import com.owncloud.android.databinding.UploadListLayoutBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation +import com.owncloud.android.operations.CheckCurrentCredentialsOperation +import com.owncloud.android.operations.factory.UploadFileOperationFactory +import com.owncloud.android.ui.adapter.uploadList.UploadListAdapter +import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterAction +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListItemOnClick +import com.owncloud.android.ui.decoration.MediaGridItemDecoration +import com.owncloud.android.utils.FilesSyncHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@Suppress("MagicNumber") +class UploadListActivity : + FileActivity(), + UploadListItemOnClick { + @Inject lateinit var uploadsStorageManager: UploadsStorageManager + + @Inject lateinit var powerManagementService: PowerManagementService + + @Inject lateinit var clock: Clock + + @Inject lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject lateinit var localBroadcastManager: LocalBroadcastManager + + @Inject lateinit var throttler: Throttler + + @Inject lateinit var uploadFileOperationFactory: UploadFileOperationFactory + + private var swipeListRefreshLayout: SwipeRefreshLayout? = null + private var binding: UploadListLayoutBinding? = null + + private var uploadFinishReceiver: UploadFinishReceiver? = null + private lateinit var uploadListAdapter: UploadListAdapter + private lateinit var adapterActionHandler: UploadListAdapterAction + private lateinit var adapterHelper: UploadListAdapterHelper + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + throttler.intervalMillis = 1000 + binding = UploadListLayoutBinding.inflate(layoutInflater) + val binding = binding!! + setContentView(binding.getRoot()) + swipeListRefreshLayout = binding.swipeContainingList + + // this activity has no file really bound, it's for multiple accounts at the same time; should no inherit + // from FileActivity; moreover, some behaviours inherited from FileActivity should be delegated to Fragments; + // but that's other story + file = null + + setupToolbar() + updateActionBarTitleAndHomeButtonByString(getString(R.string.uploads_view_title)) + setupDrawer(menuItemId) + setupContent() + } + + override fun getMenuItemId() = R.id.nav_uploads + + private fun setupContent() { + setupEmptyList() + adapterActionHandler = UploadListAdapterActionHandler() + adapterHelper = UploadListAdapterHelper(this) + uploadListAdapter = UploadListAdapter( + this, + uploadsStorageManager, + userAccountManager, + connectivityService, + powerManagementService, + viewThemeUtils, + this, + adapterHelper + ) + + val lm = GridLayoutManager(this, 1) + uploadListAdapter.setLayoutManager(lm) + + val spacing = getResources().getDimensionPixelSize(R.dimen.media_grid_spacing) + binding?.list?.run { + addItemDecoration(MediaGridItemDecoration(spacing)) + setLayoutManager(lm) + setAdapter(uploadListAdapter) + } + + swipeListRefreshLayout?.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } + swipeListRefreshLayout?.setOnRefreshListener { this.refresh() } + loadItems() + } + + private fun setupEmptyList() { + binding?.run { + list.setEmptyView(emptyList.getRoot()) + emptyList.run { + root.visibility = View.GONE + + emptyListIcon.run { + setImageResource(R.drawable.uploads) + getDrawable().mutate() + setAlpha(0.5f) + setVisibility(View.VISIBLE) + } + + emptyListViewHeadline.text = getString(R.string.upload_list_empty_headline) + + emptyListViewText.run { + text = getString(R.string.upload_list_empty_text_auto_upload) + visibility = View.VISIBLE + } + } + } + } + + private fun loadItems() { + swipeListRefreshLayout?.isRefreshing = true + uploadListAdapter.loadUploadItemsFromDb { swipeListRefreshLayout?.isRefreshing = false } + } + + private fun refresh() { + val isUploadStarted = FileUploadHelper.instance().retryFailedUploads( + uploadsStorageManager, + connectivityService, + accountManager, + powerManagementService + ) + + if (!isUploadStarted) { + uploadListAdapter.loadUploadItemsFromDb { swipeListRefreshLayout?.isRefreshing = false } + } + } + + override fun onStart() { + Log_OC.v(TAG, "onStart() start") + super.onStart() + + highlightNavigationViewItem(menuItemId) + + uploadFinishReceiver = UploadFinishReceiver() + val intentFilter = IntentFilter().apply { + addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_ENQUEUED) + addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_STARTED) + addAction(FileUploadEventBroadcaster.ACTION_UPLOAD_COMPLETED) + } + uploadFinishReceiver?.let { localBroadcastManager.registerReceiver(it, intentFilter) } + + Log_OC.v(TAG, "onStart() end") + } + + override fun onStop() { + Log_OC.v(TAG, "onStop() start") + if (uploadFinishReceiver != null) { + uploadFinishReceiver?.let { localBroadcastManager.unregisterReceiver(it) } + uploadFinishReceiver = null + } + super.onStop() + Log_OC.v(TAG, "onStop() end") + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_upload_list, menu) + menu.findItem(R.id.action_toggle_global_pause)?.let { updateGlobalPauseIcon(it) } + return true + } + + private fun updateGlobalPauseIcon(item: MenuItem) { + val paused = preferences.isGlobalUploadPaused() + item.setIcon(if (paused) R.drawable.ic_global_resume else R.drawable.ic_global_pause) + item.title = getString( + if (paused) { + R.string.upload_action_global_upload_resume + } else { + R.string.upload_action_global_upload_pause + } + ) + } + + @SuppressLint("NotifyDataSetChanged") + private fun toggleGlobalPause(item: MenuItem) { + preferences.setGlobalUploadPaused(!preferences.isGlobalUploadPaused()) + updateGlobalPauseIcon(item) + val uploadHelper = FileUploadHelper.instance() + accountManager.getAllUsers().filterNotNull().forEach { user -> + val ids = uploadsStorageManager.getCurrentUploadIds(user.accountName) + uploadHelper.cancelAndRestartUploadJob(user, ids) + } + uploadListAdapter.notifyDataSetChanged() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + if (isDrawerOpen) closeDrawer() else openDrawer() + true + } + + R.id.action_toggle_global_pause -> { + toggleGlobalPause(item) + true + } + + else -> super.onOptionsItemSelected(item) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE__UPDATE_CREDENTIALS && resultCode == RESULT_OK) { + FilesSyncHelper.restartUploadsIfNeeded( + uploadsStorageManager, + userAccountManager, + connectivityService, + powerManagementService + ) + } + } + + override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>) { + if (operation !is CheckCurrentCredentialsOperation) { + super.onRemoteOperationFinish(operation, result) + return + } + + fileOperationsHelper.opIdWaitingFor = Long.MAX_VALUE + dismissLoadingDialog() + val account = result.data[0] as? Account + if (!result.isSuccess) { + requestCredentialsUpdate(account) + } else { + FilesSyncHelper.restartUploadsIfNeeded( + uploadsStorageManager, + userAccountManager, + connectivityService, + powerManagementService + ) + } + } + + private var conflictSnackbar: Snackbar? = null + + override fun onLastUploadResultConflictClick(upload: OCUpload) { + val rootView = binding?.root ?: return + + conflictSnackbar = Snackbar.make( + rootView, + R.string.upload_sync_conflict_checking, + Snackbar.LENGTH_INDEFINITE + ).apply { show() } + + lifecycleScope.launch { + val client = clientRepository.getOwncloudClient() ?: return@launch + val result = adapterActionHandler.handleConflict(upload, client, uploadsStorageManager) + + withContext(Dispatchers.Main) { + when (result) { + is ConflictHandlingResult.ConflictNotExistsRemoteFileNotFound -> { + showConflictSnackbar(R.string.upload_sync_conflict_not_exists) + retryUpload(upload) + } + + is ConflictHandlingResult.CannotCheckConflict -> { + showConflictSnackbar(R.string.upload_sync_conflict_check_error) + } + + is ConflictHandlingResult.ShowConflictResolveDialog -> { + conflictSnackbar?.dismiss() + adapterHelper.openConflictActivity(result.file, result.upload) + } + + is ConflictHandlingResult.ConflictNotExistsSameFile -> { + showConflictSnackbar(R.string.upload_sync_conflict_same_file) + } + } + } + } + } + + private fun retryUpload(upload: OCUpload) { + lifecycleScope.launch(Dispatchers.IO) { + val client = clientRepository.getOwncloudClient() + + // Check parent folder exists + val parentPath = storageManager + .getFileByPath(upload.remotePath) + .parentRemotePath + ?: upload.remotePath.webDavParentPath() + + val checkOp = ExistenceCheckRemoteOperation(parentPath, false) + val checkResult = checkOp.execute(client) + + if (!checkResult.isSuccess && + checkResult.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND + ) { + withContext(Dispatchers.Main) { + showConflictSnackbar(R.string.uploader_file_not_found_message) + } + return@launch + } + + val result = uploadFileOperationFactory + .create(upload) + .execute(client) + + if (result.isSuccess) { + withContext(Dispatchers.Main) { + uploadListAdapter.loadUploadItemsFromDb() + } + } + } + } + + private fun showConflictSnackbar(messageId: Int) { + conflictSnackbar?.apply { + setText(messageId) + setDuration(Snackbar.LENGTH_LONG) + show() + } + } + + private inner class UploadFinishReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + throttler.run("update_upload_list") { uploadListAdapter.loadUploadItemsFromDb() } + } + } + + companion object { + private val TAG: String = UploadListActivity::class.java.getSimpleName() + + fun createIntent(file: OCFile?, user: User?, flag: Int?, context: Context?): Intent = + Intent(context, UploadListActivity::class.java).apply { + if (flag != null) { + setFlags(flags or flag) + } + putExtra(EXTRA_FILE, file) + putExtra(EXTRA_USER, user) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java new file mode 100644 index 000000000000..9743507c85f2 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java @@ -0,0 +1,374 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2017-2020 Andy Scherzinger + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2020 Chawki Chouib + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-FileCopyrightText: 2026 Daniele Verducci backgroundImageTarget = createBackgroundImageTarget(backgroundImageView); + GlideHelper.INSTANCE.loadIntoTarget(this, + accountManager.getCurrentOwnCloudAccount(), + backgroundURL, + backgroundImageTarget, + R.drawable.background); + } + + private Target createBackgroundImageTarget(ImageView backgroundImageView) { + return new CustomTarget<>() { + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + Drawable[] drawables = { + viewThemeUtils.platform.getPrimaryColorDrawable(backgroundImageView.getContext()), + resource + }; + LayerDrawable layerDrawable = new LayerDrawable(drawables); + backgroundImageView.setImageDrawable(layerDrawable); + } + + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + Drawable fallback = ResourcesCompat.getDrawable(backgroundImageView.getResources(), R.drawable.background, null); + + Drawable[] drawables = { + viewThemeUtils.platform.getPrimaryColorDrawable(backgroundImageView.getContext()), + fallback + }; + LayerDrawable layerDrawable = new LayerDrawable(drawables); + backgroundImageView.setImageDrawable(layerDrawable); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + + } + }; + } + + + private void populateUserInfoUi(UserInfo userInfo) { + binding.userinfoUsername.setText(user.getAccountName()); + binding.userinfoIcon.setTag(user.getAccountName()); + DisplayUtils.setAvatar(user, + this, + mCurrentAccountAvatarRadiusDimension, + getResources(), + binding.userinfoIcon, + this); + + if (!TextUtils.isEmpty(userInfo.getDisplayName())) { + binding.userinfoFullName.setText(userInfo.getDisplayName()); + } + + if (TextUtils.isEmpty(userInfo.getPhone()) && TextUtils.isEmpty(userInfo.getEmail()) + && TextUtils.isEmpty(userInfo.getAddress()) && TextUtils.isEmpty(userInfo.getTwitter()) + && TextUtils.isEmpty(userInfo.getWebsite())) { + binding.userinfoList.setVisibility(View.GONE); + binding.loadingContent.setVisibility(View.GONE); + binding.emptyList.emptyListView.setVisibility(View.VISIBLE); + + setErrorMessageForMultiList(getString(R.string.userinfo_no_info_headline), + getString(R.string.userinfo_no_info_text), R.drawable.ic_user_outline); + } else { + binding.loadingContent.setVisibility(View.VISIBLE); + binding.emptyList.emptyListView.setVisibility(View.GONE); + + Map> list = new HashMap<>(); + list.put(UserInfoAdapter.SECTION_USERINFO, createUserInfoDetails(userInfo)); + list.put(UserInfoAdapter.SECTION_GROUPS, createGroupInfoDetails(userInfo)); + binding.userinfoList.setAdapter(new UserInfoAdapter(this, list, viewThemeUtils)); + + binding.loadingContent.setVisibility(View.GONE); + binding.userinfoList.setVisibility(View.VISIBLE); + } + } + + private LinkedList createUserInfoDetails(UserInfo userInfo) { + LinkedList result = new LinkedList<>(); + + addToListIfNeeded(result, R.drawable.ic_phone, userInfo.getPhone(), R.string.user_info_phone); + addToListIfNeeded(result, R.drawable.ic_email, userInfo.getEmail(), R.string.user_info_email); + addToListIfNeeded(result, R.drawable.ic_map_marker, userInfo.getAddress(), R.string.user_info_address); + addToListIfNeeded(result, R.drawable.ic_web, DisplayUtils.beautifyURL(userInfo.getWebsite()), + R.string.user_info_website); + addToListIfNeeded(result, R.drawable.ic_twitter, DisplayUtils.beautifyTwitterHandle(userInfo.getTwitter()), + R.string.user_info_twitter); + + return result; + } + + private LinkedList createGroupInfoDetails(UserInfo userInfo) { + LinkedList result = new LinkedList<>(); + + if (userInfo.getGroups() != null) { + final ArrayList sortedGroups = new ArrayList<>(userInfo.getGroups()); + Collections.sort(sortedGroups); + addToListIfNeeded(result, R.drawable.ic_group, String.join(", ", sortedGroups), + R.string.user_info_groups); + } + + return result; + } + + private void addToListIfNeeded(List info, @DrawableRes int icon, String text, + @StringRes int contentDescriptionInt) { + if (!TextUtils.isEmpty(text)) { + info.add(new UserInfoAdapter.UserInfoDetailsItem(icon, text, getResources().getString(contentDescriptionInt))); + } + } + + public static void openAccountRemovalDialog(User user, FragmentManager fragmentManager) { + AccountRemovalDialog dialog = AccountRemovalDialog.newInstance(user); + dialog.show(fragmentManager, "dialog"); + } + + + + private void fetchAndSetData() { + Thread t = new Thread(() -> { + NextcloudClient nextcloudClient; + + try { + nextcloudClient = OwnCloudClientFactory.createNextcloudClient(user, + this); + } catch (AccountUtils.AccountNotFoundException e) { + Log_OC.e(this, "Error retrieving user info", e); + return; + } + + RemoteOperationResult result = new GetUserInfoRemoteOperation().execute(nextcloudClient); + + if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) { + if (result.isSuccess() && result.getResultData() != null) { + userInfo = result.getResultData(); + + runOnUiThread(() -> populateUserInfoUi(userInfo)); + } else { + // show error + runOnUiThread(() -> setErrorMessageForMultiList( + getString(R.string.user_information_retrieval_error), + result.getLogMessage(this), + R.drawable.ic_list_empty_error) + ); + Log_OC.d(TAG, result.getLogMessage()); + } + } + }); + + t.start(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (userInfo != null) { + outState.putParcelable(KEY_USER_DATA, userInfo); + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + public void onMessageEvent(TokenPushEvent event) { + PushUtils.pushRegistrationToServer(getUserAccountManager(), preferences.getPushToken()); + } + +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/CertificateCombinedExceptionViewAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/CertificateCombinedExceptionViewAdapter.java new file mode 100644 index 000000000000..c19e9fc5d4ab --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/CertificateCombinedExceptionViewAdapter.java @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Stefan Niedermann + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.adapter; + +import android.view.View; + +import com.owncloud.android.databinding.SslUntrustedCertLayoutBinding; +import com.owncloud.android.lib.common.network.CertificateCombinedException; +import com.owncloud.android.ui.dialog.SslUntrustedCertDialog; + +import androidx.annotation.NonNull; + +public class CertificateCombinedExceptionViewAdapter implements SslUntrustedCertDialog.ErrorViewAdapter { + + //private final static String TAG = CertificateCombinedExceptionViewAdapter.class.getSimpleName(); + + private final CertificateCombinedException mSslException; + + public CertificateCombinedExceptionViewAdapter(CertificateCombinedException sslException) { + mSslException = sslException; + } + + @Override + public void updateErrorView(@NonNull SslUntrustedCertLayoutBinding binding) { + /// clean + binding.reasonNoInfoAboutError.setVisibility(View.GONE); + + /// refresh + if (mSslException.getCertPathValidatorException() != null) { + binding.reasonCertNotTrusted.setVisibility(View.VISIBLE); + } else { + binding.reasonCertNotTrusted.setVisibility(View.GONE); + } + + if (mSslException.getCertificateExpiredException() != null) { + binding.reasonCertExpired.setVisibility(View.VISIBLE); + } else { + binding.reasonCertExpired.setVisibility(View.GONE); + } + + if (mSslException.getCertificateNotYetValidException() != null) { + binding.reasonCertNotYetValid.setVisibility(View.VISIBLE); + } else { + binding.reasonCertNotYetValid.setVisibility(View.GONE); + } + + if (mSslException.getSslPeerUnverifiedException() != null) { + binding.reasonHostnameNotVerified.setVisibility(View.VISIBLE); + } else { + binding.reasonHostnameNotVerified.setVisibility(View.GONE); + } + + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/CommonOCFileListAdapterInterface.kt b/app/src/main/java/com/owncloud/android/ui/adapter/CommonOCFileListAdapterInterface.kt new file mode 100644 index 000000000000..94962be8a753 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/CommonOCFileListAdapterInterface.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.FileSortOrder + +@Suppress("TooManyFunctions") +interface CommonOCFileListAdapterInterface { + fun isMultiSelect(): Boolean + fun cancelAllPendingTasks() + fun getItemPosition(file: OCFile): Int + fun swapDirectory( + user: User, + directory: OCFile, + storageManager: FileDataStorageManager, + onlyOnDevice: Boolean, + mLimitToMimeType: String + ) + + fun setHighlightedItem(file: OCFile) + fun setSortOrder(mFile: OCFile, sortOrder: FileSortOrder) + fun addCheckedFile(file: OCFile) + fun isCheckedFile(file: OCFile): Boolean + fun getCheckedItems(): Set + fun removeCheckedFile(file: OCFile) + fun notifyItemChanged(file: OCFile) + fun getFilesCount(): Int + fun setMultiSelect(isMultiSelect: Boolean) + fun clearCheckedItems() + fun selectAll(value: Boolean) +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/DashboardWidgetListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/DashboardWidgetListAdapter.kt new file mode 100644 index 000000000000..d8d6b96d285f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/DashboardWidgetListAdapter.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.android.lib.resources.dashboard.DashboardWidget +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.widget.DashboardWidgetConfigurationInterface +import com.owncloud.android.databinding.WidgetListItemBinding +import kotlinx.coroutines.CoroutineScope + +class DashboardWidgetListAdapter( + private val lifecycleScope: CoroutineScope, + val accountManager: UserAccountManager, + val clientFactory: ClientFactory, + val context: Context, + private val dashboardWidgetConfigurationInterface: DashboardWidgetConfigurationInterface +) : RecyclerView.Adapter() { + private var widgets = emptyList() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + WidgetListItemViewHolder( + lifecycleScope, + WidgetListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + accountManager, + clientFactory, + context + ) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val widgetListItemViewHolder = holder as WidgetListItemViewHolder + + widgetListItemViewHolder.bind(widgets[position], dashboardWidgetConfigurationInterface) + } + + override fun getItemCount(): Int = widgets.size + + @SuppressLint("NotifyDataSetChanged") + fun setWidgetList(list: Map?) { + widgets = list?.map { (_, value) -> value }?.sortedBy { it.order } ?: emptyList() + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/DiskLruImageCache.java b/app/src/main/java/com/owncloud/android/ui/adapter/DiskLruImageCache.java new file mode 100644 index 000000000000..0900d6704343 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/DiskLruImageCache.java @@ -0,0 +1,239 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Unpublished + * SPDX-FileCopyrightText: 2014-2017 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH + * SPDX-FileCopyrightText: 2016 Iskra Delta + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-FileCopyrightText: 2014 Jose Antonio Barros Ramos + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.adapter; + +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; + +import com.jakewharton.disklrucache.DiskLruCache; +import com.owncloud.android.BuildConfig; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.utils.BitmapUtils; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class DiskLruImageCache { + + private DiskLruCache mDiskCache; + private CompressFormat mCompressFormat; + private int mCompressQuality; + private static final int CACHE_VERSION = 1; + private static final int VALUE_COUNT = 1; + private static final int IO_BUFFER_SIZE = 8 * 1024; + private static final String CACHE_TEST_DISK = "cache_test_DISK_"; + + private static final String TAG = DiskLruImageCache.class.getSimpleName(); + + public DiskLruImageCache(File diskCacheDir, int diskCacheSize, CompressFormat compressFormat, int quality) + throws IOException { + mDiskCache = DiskLruCache.open(diskCacheDir, CACHE_VERSION, VALUE_COUNT, diskCacheSize); + mCompressFormat = compressFormat; + mCompressQuality = quality; + } + + private boolean writeBitmapToFile(Bitmap bitmap, DiskLruCache.Editor editor) throws IOException { + try (OutputStream out = new BufferedOutputStream(editor.newOutputStream(0), IO_BUFFER_SIZE)) { + return bitmap.compress(mCompressFormat, mCompressQuality, out); + } + } + + public void put(String key, Bitmap data) { + + DiskLruCache.Editor editor = null; + String validKey = convertToValidKey(key); + try { + editor = mDiskCache.edit(validKey); + if (editor == null) { + return; + } + + if (writeBitmapToFile(data, editor)) { + mDiskCache.flush(); + editor.commit(); + if (BuildConfig.DEBUG) { + Log_OC.d(CACHE_TEST_DISK, "image put on disk cache " + validKey); + } + } else { + editor.abort(); + if (BuildConfig.DEBUG) { + Log_OC.d(CACHE_TEST_DISK, "ERROR on: image put on disk cache " + validKey); + } + } + } catch (IOException e) { + if (BuildConfig.DEBUG) { + Log_OC.d(CACHE_TEST_DISK, "ERROR on: image put on disk cache " + validKey); + } + try { + if (editor != null) { + editor.abort(); + } + } catch (IOException ex) { + Log_OC.d(TAG, "Error aborting editor", ex); + } + } + } + + public Bitmap getScaledBitmap(String key, int width, int height) { + Bitmap bitmap = null; + String validKey = convertToValidKey(key); + + try (DiskLruCache.Snapshot snapshot = mDiskCache.get(validKey)) { + if (snapshot == null) { + return null; + } + + InputStream inputStream = snapshot.getInputStream(0); + if (inputStream != null) { + // First decode with inJustDecodeBounds=true to check dimensions + final BitmapFactory.Options options = new BitmapFactory.Options(); + try (BufferedInputStream buffIn = new BufferedInputStream(inputStream, IO_BUFFER_SIZE)) { + options.inScaled = true; + options.inPurgeable = true; + options.inPreferQualityOverSpeed = false; + options.inMutable = false; + options.inJustDecodeBounds = true; + + BitmapFactory.decodeStream(buffIn, null, options); + } + + try (DiskLruCache.Snapshot snapshot2 = mDiskCache.get(validKey)) { + inputStream = snapshot2.getInputStream(0); + + try (BufferedInputStream buffIn = new BufferedInputStream(inputStream, IO_BUFFER_SIZE)) { + // Calculate inSampleSize + options.inSampleSize = BitmapUtils.calculateSampleFactor(options, width, height); + + // Decode bitmap with inSampleSize set + options.inJustDecodeBounds = false; + bitmap = BitmapFactory.decodeStream(buffIn, null, options); + } + } + } + } catch (Exception e) { + Log_OC.e(TAG, e.getMessage(), e); + } + + if (BuildConfig.DEBUG) { + Log_OC.d(CACHE_TEST_DISK, bitmap == null ? "not found" : "image read from disk " + validKey); + } + + return bitmap; + } + + public Bitmap getBitmap(String key) { + Bitmap bitmap = null; + DiskLruCache.Snapshot snapshot = null; + InputStream in = null; + BufferedInputStream buffIn = null; + String validKey = convertToValidKey(key); + + try { + snapshot = mDiskCache.get(validKey); + if (snapshot == null) { + return null; + } + in = snapshot.getInputStream(0); + if (in != null) { + buffIn = new BufferedInputStream(in, IO_BUFFER_SIZE); + bitmap = BitmapFactory.decodeStream(buffIn); + } + } catch (IOException e) { + Log_OC.e(TAG, e.getMessage(), e); + } finally { + if (snapshot != null) { + snapshot.close(); + } + if (buffIn != null) { + try { + buffIn.close(); + } catch (IOException e) { + Log_OC.e(TAG, e.getMessage(), e); + } + } + if (in != null) { + try { + in.close(); + } catch (IOException e) { + Log_OC.e(TAG, e.getMessage(), e); + } + } + } + + if (BuildConfig.DEBUG) { + Log_OC.d(CACHE_TEST_DISK, bitmap == null ? "not found" : "image read from disk " + validKey); + } + + return bitmap; + } + + public boolean containsKey(String key) { + + boolean contained = false; + DiskLruCache.Snapshot snapshot = null; + String validKey = convertToValidKey(key); + try { + snapshot = mDiskCache.get(validKey); + contained = snapshot != null; + } catch (IOException e) { + Log_OC.d(TAG, e.getMessage(), e); + } finally { + if (snapshot != null) { + snapshot.close(); + } + } + + return contained; + + } + + public void clearCache() { + if (BuildConfig.DEBUG) { + Log_OC.d(CACHE_TEST_DISK, "disk cache CLEARED"); + } + try { + mDiskCache.delete(); + } catch (IOException e) { + Log_OC.d(TAG, e.getMessage(), e); + } + } + + public File getCacheFolder() { + return mDiskCache.getDirectory(); + } + + private String convertToValidKey(String key) { + return Integer.toString(key.hashCode()); + } + + /** + * Remove passed key from cache + * + * @param key + */ + public void removeKey(String key) { + String validKey = convertToValidKey(key); + try { + mDiskCache.remove(validKey); + Log_OC.d(TAG, "removeKey from cache: " + validKey); + } catch (IOException e) { + Log_OC.d(TAG, e.getMessage(), e); + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FeaturesViewAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/FeaturesViewAdapter.java new file mode 100644 index 000000000000..f4536999af48 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FeaturesViewAdapter.java @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import com.owncloud.android.features.FeatureItem; +import com.owncloud.android.ui.fragment.FeatureFragment; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +public class FeaturesViewAdapter extends FragmentStateAdapter { + + private final FeatureItem[] mFeatures; + + public FeaturesViewAdapter(FragmentActivity fragmentActivity, FeatureItem... features) { + super(fragmentActivity); + mFeatures = features; + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return FeatureFragment.newInstance(mFeatures[position]); + } + + @Override + public int getItemCount() { + return mFeatures.length; + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FeaturesWebViewAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/FeaturesWebViewAdapter.java new file mode 100644 index 000000000000..2de789b24ce1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FeaturesWebViewAdapter.java @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import com.owncloud.android.ui.fragment.FeatureWebFragment; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +public class FeaturesWebViewAdapter extends FragmentStateAdapter { + private String[] mWebUrls; + + public FeaturesWebViewAdapter(FragmentActivity fragmentActivity, String... webUrls) { + super(fragmentActivity); + mWebUrls = webUrls; + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return FeatureWebFragment.newInstance(mWebUrls[position]); + } + + @Override + public int getItemCount() { + return mWebUrls.length; + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java new file mode 100644 index 000000000000..9f0f271e494c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import com.nextcloud.client.account.User; +import com.nextcloud.ui.ImageDetailFragment; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment; +import com.owncloud.android.ui.fragment.FileDetailSharingFragment; +import com.owncloud.android.utils.MimeTypeUtil; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +/** + * File details pager adapter. + */ +public class FileDetailTabAdapter extends FragmentStateAdapter { + private final OCFile file; + private final User user; + private final boolean showSharingTab; + + private FileDetailSharingFragment fileDetailSharingFragment; + private FileDetailActivitiesFragment fileDetailActivitiesFragment; + private ImageDetailFragment imageDetailFragment; + + public FileDetailTabAdapter(FragmentActivity fragmentActivity, + OCFile file, + User user, + boolean showSharingTab) { + super(fragmentActivity); + this.file = file; + this.user = user; + this.showSharingTab = showSharingTab; + } + + public FileDetailSharingFragment getFileDetailSharingFragment() { + return fileDetailSharingFragment; + } + + public FileDetailActivitiesFragment getFileDetailActivitiesFragment() { + return fileDetailActivitiesFragment; + } + + public ImageDetailFragment getImageDetailFragment() { + return imageDetailFragment; + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return switch (position) { + case 1 -> { + fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user); + yield fileDetailSharingFragment; + } + case 2 -> { + imageDetailFragment = ImageDetailFragment.newInstance(file, user); + yield imageDetailFragment; + } + default -> { + fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user); + yield fileDetailActivitiesFragment; + } + }; + } + + @Override + public int getItemCount() { + if (showSharingTab) { + if (MimeTypeUtil.isImage(file)) { + return 3; + } + return 2; + } else { + if (MimeTypeUtil.isImage(file)) { + return 2; + } + return 1; + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FilterableListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/FilterableListAdapter.java new file mode 100644 index 000000000000..49c879b2d067 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FilterableListAdapter.java @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2016 Tobias Kaminsky + * SPDX-FileCopyrightText: 2016 Nextcloud + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +public interface FilterableListAdapter { + void filter(String text); +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt new file mode 100644 index 000000000000..864e856999c5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -0,0 +1,400 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * @author TSI-mc + * Copyright (C) 2022 Tobias Kaminsky + * Copyright (C) 2022 Nextcloud GmbH + * Copyright (C) 2023 TSI-mc + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.ui.adapter + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter +import com.afollestad.sectionedrecyclerview.SectionedViewHolder +import com.nextcloud.client.account.User +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.databinding.GalleryHeaderBinding +import com.owncloud.android.databinding.GalleryRowBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.GalleryItems +import com.owncloud.android.datamodel.GalleryRow +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.ComponentsGetter +import com.owncloud.android.ui.fragment.GalleryFragment +import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog +import com.owncloud.android.ui.fragment.SearchType +import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import me.zhanghai.android.fastscroll.PopupTextProvider +import java.util.Calendar +import java.util.Date + +@Suppress("LongParameterList", "TooManyFunctions") +class GalleryAdapter( + val context: Context, + user: User, + ocFileListFragmentInterface: OCFileListFragmentInterface, + preferences: AppPreferences, + transferServiceGetter: ComponentsGetter, + private val viewThemeUtils: ViewThemeUtils, + var columns: Int, + private val defaultThumbnailSize: Int +) : SectionedRecyclerViewAdapter(), + CommonOCFileListAdapterInterface, + PopupTextProvider { + + companion object { + private const val TAG = "GalleryAdapter" + } + + // fileId -> (section, row) + private val filePositionMap = mutableMapOf>() + + // (section, row) -> unique stable ID for that row + private val rowIdMap = mutableMapOf, Long>() + + private var cachedAllFiles: List? = null + private var cachedFilesCount: Int = 0 + + private var _files: List = mutableListOf() + var files: List + get() = _files + private set(value) { + _files = value + invalidateCaches() + } + + private val ocFileListDelegate: OCFileListDelegate + private var storageManager: FileDataStorageManager = transferServiceGetter.storageManager + + init { + ocFileListDelegate = OCFileListDelegate( + transferServiceGetter.fileUploaderHelper, + context, + ocFileListFragmentInterface, + user, + storageManager, + false, + preferences, + true, + transferServiceGetter, + showMetadata = false, + showShareAvatar = false, + viewThemeUtils + ) + } + + private fun invalidateCaches() { + Log_OC.d(TAG, "invalidating caches") + cachedAllFiles = null + updateFilesCount() + rebuildFilePositionMap() + } + + private fun updateFilesCount() { + cachedFilesCount = files.sumOf { it.rows.sumOf { it.files.size } } + } + + private fun rebuildFilePositionMap() { + filePositionMap.clear() + rowIdMap.clear() + + files.forEachIndexed { sectionIndex, galleryItem -> + galleryItem.rows.forEachIndexed { rowIndex, row -> + val position = sectionIndex to rowIndex + + // since row can contain files two to five use first files id as adapter id + row.files.firstOrNull()?.fileId?.let { firstFileId -> + rowIdMap[position] = firstFileId + } + + // map all row files + row.files.forEach { file -> + filePositionMap[file.fileId] = position + } + } + } + } + + override fun getItemId(section: Int, position: Int): Long = rowIdMap[section to position] ?: -1L + + override fun getItemCount(section: Int): Int = files.getOrNull(section)?.rows?.size ?: 0 + + override fun getSectionCount(): Int = files.size + + override fun getFilesCount(): Int = cachedFilesCount + + override fun getItemPosition(file: OCFile): Int { + val (section, row) = filePositionMap[file.fileId] ?: return -1 + return getAbsolutePosition(section, row) + } + + override fun selectAll(value: Boolean) { + if (value) { + addAllFilesToCheckedFiles() + } else { + clearCheckedItems() + } + } + + override fun showFooters(): Boolean = false + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionedViewHolder = + if (viewType == VIEW_TYPE_HEADER) { + GalleryHeaderViewHolder( + GalleryHeaderBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } else { + GalleryRowHolder( + GalleryRowBinding.inflate(LayoutInflater.from(parent.context), parent, false), + defaultThumbnailSize.toFloat(), + ocFileListDelegate, + storageManager, + this, + viewThemeUtils + ) + } + + override fun onBindViewHolder( + holder: SectionedViewHolder?, + section: Int, + relativePosition: Int, + absolutePosition: Int + ) { + if (holder is GalleryRowHolder) { + val row = files.getOrNull(section)?.rows?.getOrNull(relativePosition) + row?.let { holder.bind(it) } + } + } + + override fun getPopupText(p0: View, position: Int): CharSequence = DisplayUtils.getDateByPattern( + files[getRelativePosition(position).section()].date, + context, + DisplayUtils.MONTH_YEAR_PATTERN + ) + + override fun onBindHeaderViewHolder(holder: SectionedViewHolder?, section: Int, expanded: Boolean) { + if (holder != null) { + val headerViewHolder = holder as GalleryHeaderViewHolder + val galleryItem = files[section] + + headerViewHolder.binding.month.text = DisplayUtils.getDateByPattern( + galleryItem.date, + context, + DisplayUtils.MONTH_PATTERN + ) + headerViewHolder.binding.year.text = DisplayUtils.getDateByPattern( + galleryItem.date, + context, + DisplayUtils.YEAR_PATTERN + ) + } + } + + @SuppressLint("NotifyDataSetChanged") + fun showAllGalleryItems( + remotePath: String, + mediaState: GalleryFragmentBottomSheetDialog.MediaState, + photoFragment: GalleryFragment + ) { + val items = storageManager.allGalleryItems + + val filteredList = items.filter { it != null && it.remotePath.startsWith(remotePath) } + + setMediaFilter( + filteredList, + mediaState, + photoFragment + ) + } + + // Set Image/Video List According to Selection of Hide/Show Image/Video + @SuppressLint("NotifyDataSetChanged") + private fun setMediaFilter( + items: List, + mediaState: GalleryFragmentBottomSheetDialog.MediaState, + photoFragment: GalleryFragment + ) { + val finalSortedList: List = when (mediaState) { + GalleryFragmentBottomSheetDialog.MediaState.MEDIA_STATE_PHOTOS_ONLY -> { + items.filter { MimeTypeUtil.isImage(it.mimeType) }.distinct() + } + + GalleryFragmentBottomSheetDialog.MediaState.MEDIA_STATE_VIDEOS_ONLY -> { + items.filter { MimeTypeUtil.isVideo(it.mimeType) }.distinct() + } + + else -> items + } + + if (finalSortedList.isEmpty()) { + photoFragment.setEmptyListMessage(SearchType.GALLERY_SEARCH) + } + + files = finalSortedList.toGalleryItems() + notifyDataSetChanged() + } + + private fun transformToRows(list: List): List { + if (list.isEmpty()) return emptyList() + + return list + .sortedByDescending { it.modificationTimestamp } + .chunked(columns) + .map { chunk -> GalleryRow(chunk, defaultThumbnailSize, defaultThumbnailSize) } + } + + @SuppressLint("NotifyDataSetChanged") + fun clear() { + files = emptyList() + notifyDataSetChanged() + } + + private fun firstOfMonth(timestamp: Long): Long = Calendar.getInstance().apply { + time = Date(timestamp) + set(Calendar.DAY_OF_MONTH, getActualMinimum(Calendar.DAY_OF_MONTH)) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + }.timeInMillis + + fun isEmpty(): Boolean = files.isEmpty() + + fun getItem(position: Int): OCFile? { + val itemCoordinates = getRelativePosition(position) + + return files + .getOrNull(itemCoordinates.section()) + ?.rows + ?.getOrNull(itemCoordinates.relativePos()) + ?.files + ?.getOrNull(0) + } + + override fun isMultiSelect(): Boolean = ocFileListDelegate.isMultiSelect + + override fun cancelAllPendingTasks() { + ocFileListDelegate.cancelAllPendingTasks() + } + + override fun addCheckedFile(file: OCFile) { + ocFileListDelegate.addCheckedFile(file) + } + + override fun isCheckedFile(file: OCFile): Boolean = ocFileListDelegate.isCheckedFile(file) + + override fun getCheckedItems(): Set = ocFileListDelegate.checkedItems + + override fun removeCheckedFile(file: OCFile) { + ocFileListDelegate.removeCheckedFile(file) + } + + override fun notifyItemChanged(file: OCFile) { + val position = getItemPosition(file) + if (position >= 0) { + notifyItemChanged(position) + } + } + + /** + * Enables or disables multi-select mode in the gallery. + * + * When multi-select mode is enabled: + * - Checkboxes are shown for all items. + * - Users can select multiple files. + * + * When multi-select mode is disabled: + * - Checkboxes are hidden. + * - Selected files remain visually unselected. + * + * Note: + * - This function is only called when the user explicitly enters or exits multi-select mode. + * It is **not** called for individual file selection or deselection. + * - The entire adapter is refreshed using [notifyDataSetChanged] to properly show or hide + * checkboxes across all rows, as individual item updates are not sufficient in this case. + * + * @param isMultiSelect true to enable multi-select mode, false to disable it. + */ + @SuppressLint("NotifyDataSetChanged") + override fun setMultiSelect(isMultiSelect: Boolean) { + ocFileListDelegate.isMultiSelect = isMultiSelect + notifyDataSetChanged() + } + + private fun getAllFiles(): List = cachedAllFiles ?: files.flatMap { galleryItem -> + galleryItem.rows.flatMap { row -> row.files } + }.also { cachedAllFiles = it } + + private fun addAllFilesToCheckedFiles() { + val allFiles = getAllFiles() + ocFileListDelegate.addToCheckedFiles(allFiles) + } + + override fun clearCheckedItems() { + ocFileListDelegate.clearCheckedItems() + } + + @VisibleForTesting + fun addFiles(items: List) { + files = items + } + + fun changeColumn(newColumn: Int) { + columns = newColumn + } + + fun markAsFavorite(remotePath: String, favorite: Boolean) { + val allFiles = getAllFiles() + allFiles.firstOrNull { it.remotePath == remotePath }?.also { file -> + file.isFavorite = favorite + files = allFiles.toGalleryItems() + notifyItemChanged(file) + } + } + + private fun List.toGalleryItems(): List { + if (isEmpty()) return emptyList() + + return groupBy { firstOfMonth(it.modificationTimestamp) } + .map { (date, filesList) -> + GalleryItems(date, transformToRows(filesList)) + } + .sortedByDescending { it.date } + } + + override fun onBindFooterViewHolder(holder: SectionedViewHolder?, section: Int) = Unit + + override fun swapDirectory( + user: User, + directory: OCFile, + storageManager: FileDataStorageManager, + onlyOnDevice: Boolean, + mLimitToMimeType: String + ) = Unit + + override fun setHighlightedItem(file: OCFile) = Unit + + override fun setSortOrder(mFile: OCFile, sortOrder: FileSortOrder) = Unit + + fun cleanup() { + ocFileListDelegate.cleanup() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryHeaderViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryHeaderViewHolder.kt new file mode 100644 index 000000000000..648a143bdede --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryHeaderViewHolder.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import com.afollestad.sectionedrecyclerview.SectionedViewHolder +import com.owncloud.android.databinding.GalleryHeaderBinding + +class GalleryHeaderViewHolder(val binding: GalleryHeaderBinding) : SectionedViewHolder(binding.root) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt new file mode 100644 index 000000000000..fead84f34822 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt @@ -0,0 +1,202 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.view.Gravity +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.core.content.ContextCompat +import androidx.core.view.get +import com.afollestad.sectionedrecyclerview.SectionedViewHolder +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.utils.OCFileUtils +import com.nextcloud.utils.extensions.makeRounded +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.R +import com.owncloud.android.databinding.GalleryRowBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.GalleryRow +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.theme.ViewThemeUtils + +@Suppress("LongParameterList") +class GalleryRowHolder( + val binding: GalleryRowBinding, + private val defaultThumbnailSize: Float, + private val ocFileListDelegate: OCFileListDelegate, + val storageManager: FileDataStorageManager, + galleryAdapter: GalleryAdapter, + private val viewThemeUtils: ViewThemeUtils +) : SectionedViewHolder(binding.root) { + val context = galleryAdapter.context + + private lateinit var currentRow: GalleryRow + + // Cached values + private val zero by lazy { context.resources.getInteger(R.integer.zero) } + private val smallMargin by lazy { context.resources.getInteger(R.integer.small_margin) } + private val iconRadius by lazy { context.resources.getDimension(R.dimen.activity_icon_radius) } + private val standardMargin by lazy { context.resources.getDimension(R.dimen.standard_margin) } + private val checkBoxMargin by lazy { context.resources.getDimension(R.dimen.standard_quarter_padding) } + + private val checkedDrawable by lazy { + ContextCompat.getDrawable(context, R.drawable.ic_checkbox_marked)?.also { + viewThemeUtils.platform.tintDrawable(context, it, ColorRole.PRIMARY) + } + } + + private val uncheckedDrawable by lazy { + ContextCompat.getDrawable(context, R.drawable.ic_checkbox_blank_outline) + } + + private var lastFileCount = -1 + // endregion + + fun bind(row: GalleryRow) { + currentRow = row + val requiredCount = row.files.size + + // Only rebuild if file count changed + if (lastFileCount != requiredCount) { + binding.rowLayout.removeAllViews() + row.files.forEach { file -> + binding.rowLayout.addView(getRowLayout(file)) + } + lastFileCount = requiredCount + } + + val dimensions = getDimensions(row) + + for (i in row.files.indices) { + val dim = dimensions.getOrNull(i) ?: (defaultThumbnailSize.toInt() to defaultThumbnailSize.toInt()) + adjustFile(i, row.files[i], dim, row) + } + } + + fun updateRowVisuals() = bind(currentRow) + + private fun getRowLayout(file: OCFile): FrameLayout { + val (width, height) = OCFileUtils.getImageSize(file, defaultThumbnailSize) + + val checkbox = ImageView(context).apply { + visibility = View.GONE + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ).apply { + gravity = Gravity.TOP or Gravity.START + marginStart = checkBoxMargin.toInt() + topMargin = checkBoxMargin.toInt() + } + } + + val shimmer = LoaderImageView(context).apply { + setImageResource(R.drawable.background) + resetLoader() + layoutParams = FrameLayout.LayoutParams(width, height) + } + + val drawable = OCFileUtils.getMediaPlaceholder(file, width to height) + val rowCellImageView = ImageView(context).apply { + setImageDrawable(drawable) + adjustViewBounds = true + scaleType = ImageView.ScaleType.CENTER_CROP + layoutParams = FrameLayout.LayoutParams(width, height) + } + + return FrameLayout(context).apply { + addView(shimmer) + addView(rowCellImageView) + addView(checkbox) + } + } + + private fun getDimensions(row: GalleryRow): List> { + val screenWidthPx = context.resources.displayMetrics.widthPixels.toFloat() + val marginPx = smallMargin.toFloat() + val totalMargins = marginPx * (row.files.size - 1) + val availableWidth = screenWidthPx - totalMargins + + val aspectRatios = row.files.map { file -> + val (w, h) = OCFileUtils.getImageSize(file, defaultThumbnailSize) + if (h > 0) w.toFloat() / h else 1.0f + } + + val sumAspectRatios = aspectRatios.sum() + + // calculate row height based on aspect ratios + val rowHeightFloat = if (sumAspectRatios > 0) availableWidth / sumAspectRatios else defaultThumbnailSize + val finalHeight = rowHeightFloat.toInt() + + // for each aspect ratio calculate widths + val finalWidths = aspectRatios.map { ratio -> (rowHeightFloat * ratio).toInt() }.toMutableList() + val usedWidth = finalWidths.sum() + + // based on screen width get remaining pixels + val remainingPixels = (availableWidth - usedWidth).toInt() + + // add to remaining pixels to last image + if (remainingPixels > 0 && finalWidths.isNotEmpty()) { + val lastIndex = finalWidths.lastIndex + finalWidths[lastIndex] = finalWidths[lastIndex] + remainingPixels + } + + return finalWidths.map { w -> w to finalHeight } + } + + private fun adjustFile(index: Int, file: OCFile, dims: Pair, row: GalleryRow) { + val (width, height) = dims + val frameLayout = binding.rowLayout[index] as FrameLayout + val shimmer = frameLayout[0] as LoaderImageView + val thumbnail = frameLayout[1] as ImageView + val checkbox = frameLayout[2] as ImageView + + val isChecked = ocFileListDelegate.isCheckedFile(file) + adjustRowCell(thumbnail, isChecked) + adjustCheckBox(checkbox, isChecked) + + ocFileListDelegate.bindGalleryRow(shimmer, thumbnail, file, this, dims) + + val endMargin = if (index < row.files.size - 1) smallMargin else zero + thumbnail.layoutParams = FrameLayout.LayoutParams(width, height).apply { + setMargins(0, 0, endMargin, smallMargin) + } + shimmer.layoutParams = FrameLayout.LayoutParams(width, height) + frameLayout.requestLayout() + } + + @Suppress("MagicNumber") + private fun adjustRowCell(imageView: ImageView, isChecked: Boolean) { + val scale = if (isChecked) 0.8f else 1.0f + val radius = if (isChecked) iconRadius else 0f + imageView.scaleX = scale + imageView.scaleY = scale + imageView.makeRounded(context, radius) + } + + private fun adjustCheckBox(imageView: ImageView, isChecked: Boolean) { + if (ocFileListDelegate.isMultiSelect) { + val checkboxDrawable = if (isChecked) checkedDrawable else uncheckedDrawable + + checkboxDrawable?.apply { + val margin = standardMargin.toInt() + setBounds(margin, margin, margin, margin) + } + + // Only set if different + if (imageView.drawable !== checkboxDrawable) { + imageView.setImageDrawable(checkboxDrawable) + } + } + + imageView.setVisibleIf(ocFileListDelegate.isMultiSelect) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GroupFolderListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GroupFolderListAdapter.kt new file mode 100644 index 000000000000..19d05b70a6b0 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GroupFolderListAdapter.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Tobias Kaminsky + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.content.Context +import android.graphics.drawable.LayerDrawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.android.lib.resources.groupfolders.Groupfolder +import com.owncloud.android.R +import com.owncloud.android.databinding.ListItemBinding +import com.owncloud.android.ui.interfaces.GroupfolderListInterface +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.io.File + +class GroupFolderListAdapter( + val context: Context, + val viewThemeUtils: ViewThemeUtils, + private val groupFolderListInterface: GroupfolderListInterface, + private val isDarkMode: Boolean +) : RecyclerView.Adapter() { + private var list: List = listOf() + + fun setData(result: Map) { + list = result.values.sortedBy { it.mountPoint } + } + + private fun getFolderIcon(): LayerDrawable? { + val overlayDrawableId = R.drawable.ic_folder_overlay_account_group + return MimeTypeUtil.getFolderIcon(isDarkMode, overlayDrawableId, context, viewThemeUtils) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + OCFileListItemViewHolder( + ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + + override fun getItemCount(): Int = list.size + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val groupFolder = list[position] + val listHolder = holder as OCFileListItemViewHolder + + val file = File("/" + groupFolder.mountPoint) + + listHolder.apply { + fileName.text = file.name + fileSize.text = file.parentFile?.path ?: "/" + extension.visibility = View.GONE + fileSizeSeparator.visibility = View.GONE + lastModification.visibility = View.GONE + checkbox.visibility = View.GONE + overflowMenu.visibility = View.GONE + shared.visibility = View.GONE + localFileIndicator.visibility = View.GONE + favorite.visibility = View.GONE + + val folderIcon = getFolderIcon() + thumbnail.setImageDrawable(folderIcon) + itemLayout.setOnClickListener { groupFolderListInterface.onFolderClick(groupFolder.mountPoint) } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/InternalShareViewHolder.java b/app/src/main/java/com/owncloud/android/ui/adapter/InternalShareViewHolder.java new file mode 100644 index 000000000000..07211c769910 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/InternalShareViewHolder.java @@ -0,0 +1,59 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.view.View; + +import com.owncloud.android.R; +import com.owncloud.android.databinding.FileDetailsShareInternalShareLinkBinding; +import com.owncloud.android.lib.resources.shares.OCShare; + +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; +import androidx.recyclerview.widget.RecyclerView; + +class InternalShareViewHolder extends RecyclerView.ViewHolder { + private FileDetailsShareInternalShareLinkBinding binding; + private Context context; + + public InternalShareViewHolder(@NonNull View itemView) { + super(itemView); + } + + public InternalShareViewHolder(FileDetailsShareInternalShareLinkBinding binding, Context context) { + this(binding.getRoot()); + this.binding = binding; + this.context = context; + } + + public void bind(OCShare share, ShareeListAdapterListener listener) { + binding.copyInternalLinkIcon + .getBackground() + .setColorFilter(ResourcesCompat.getColor(context.getResources(), + R.color.nc_grey, + null), + PorterDuff.Mode.SRC_IN); + binding.copyInternalLinkIcon + .getDrawable() + .mutate() + .setColorFilter(ResourcesCompat.getColor(context.getResources(), + R.color.icon_on_nc_grey, + null), + PorterDuff.Mode.SRC_IN); + + if (share.isFolder()) { + binding.shareInternalLinkText.setText(context.getString(R.string.share_internal_link_to_folder_text)); + } else { + binding.shareInternalLinkText.setText(context.getString(R.string.share_internal_link_to_file_text)); + } + + binding.copyInternalContainer.setOnClickListener(l -> listener.copyInternalLink()); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncAdapter.kt new file mode 100644 index 000000000000..6c2cbe368de8 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncAdapter.kt @@ -0,0 +1,54 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.User +import com.owncloud.android.databinding.InternalTwoWaySyncViewHolderBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.theme.ViewThemeUtils + +class InternalTwoWaySyncAdapter( + private val dataStorageManager: FileDataStorageManager, + private val user: User, + val context: Context, + private val onUpdateListener: InternalTwoWaySyncAdapterOnUpdate, + private val viewThemeUtils: ViewThemeUtils +) : RecyclerView.Adapter() { + + interface InternalTwoWaySyncAdapterOnUpdate { + fun onUpdate(folderSize: Int) + } + + var folders: List = dataStorageManager.getInternalTwoWaySyncFolders(user) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InternalTwoWaySyncViewHolder { + val binding = InternalTwoWaySyncViewHolderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + viewThemeUtils.platform.colorImageView(binding.folderIcon, ColorRole.PRIMARY) + return InternalTwoWaySyncViewHolder(binding) + } + + override fun getItemCount(): Int = folders.size + + override fun onBindViewHolder(holder: InternalTwoWaySyncViewHolder, position: Int) { + holder.bind(folders[position], context, dataStorageManager, this) + } + + @SuppressLint("NotifyDataSetChanged") + fun update() { + folders = dataStorageManager.getInternalTwoWaySyncFolders(user) + notifyDataSetChanged() + onUpdateListener.onUpdate(folders.size) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncViewHolder.kt new file mode 100644 index 000000000000..a35f362bdec6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncViewHolder.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter + +import android.content.Context +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R +import com.owncloud.android.databinding.InternalTwoWaySyncViewHolderBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.DisplayUtils + +class InternalTwoWaySyncViewHolder(val binding: InternalTwoWaySyncViewHolderBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind( + folder: OCFile, + context: Context, + dataStorageManager: FileDataStorageManager, + internalTwoWaySyncAdapter: InternalTwoWaySyncAdapter + ) { + binding.run { + size.text = DisplayUtils.bytesToHumanReadable(folder.fileLength) + name.text = folder.decryptedFileName + + if (folder.internalFolderSyncResult.isEmpty()) { + syncResult.visibility = View.GONE + syncResultDivider.visibility = View.GONE + } else { + syncResult.visibility = View.VISIBLE + syncResultDivider.visibility = View.VISIBLE + syncResult.text = folder.internalFolderSyncResult + } + + if (folder.internalFolderSyncTimestamp == 0L) { + syncTimestamp.text = context.getString(R.string.internal_two_way_sync_not_yet) + } else { + syncTimestamp.text = DisplayUtils.getRelativeTimestamp( + context, + folder.internalFolderSyncTimestamp + ) + } + + unset.setOnClickListener { + folder.internalFolderSyncTimestamp = -1L + dataStorageManager.saveFile(folder) + internalTwoWaySyncAdapter.update() + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/LinkShareViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/LinkShareViewHolder.kt new file mode 100644 index 000000000000..dd6a506c1036 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/LinkShareViewHolder.kt @@ -0,0 +1,181 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * @author TSI-mc + * + * Copyright (C) 2020 Tobias Kaminsky + * Copyright (C) 2020 Nextcloud GmbH + * Copyright (C) 2021 TSI-mc + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.content.Context +import android.text.TextUtils +import android.view.View +import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.utils.extensions.remainingDownloadLimit +import com.nextcloud.utils.mdm.MDMConfig +import com.owncloud.android.R +import com.owncloud.android.databinding.FileDetailsShareLinkShareItemBinding +import com.owncloud.android.datamodel.quickPermission.QuickPermissionType +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.ui.fragment.util.SharePermissionManager.getSelectedType +import com.owncloud.android.ui.fragment.util.SharePermissionManager.isSecureFileDrop +import com.owncloud.android.utils.theme.ViewThemeUtils + +internal class LinkShareViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private var binding: FileDetailsShareLinkShareItemBinding? = null + private var context: Context? = null + private var viewThemeUtils: ViewThemeUtils? = null + private var encrypted = false + + constructor( + binding: FileDetailsShareLinkShareItemBinding, + context: Context, + viewThemeUtils: ViewThemeUtils, + encrypted: Boolean + ) : this(binding.getRoot()) { + this.binding = binding + this.context = context + this.viewThemeUtils = viewThemeUtils + this.encrypted = encrypted + } + + fun bind(publicShare: OCShare, listener: ShareeListAdapterListener, position: Int) { + val quickPermissionType = getSelectedType(publicShare, encrypted) + + setName(binding, context, publicShare, position) + setSubline(binding, context, publicShare) + setPermissionName(binding, context, publicShare, quickPermissionType) + setOnClickListeners(binding, listener, publicShare) + configureCopyLink(binding, context, listener, publicShare) + } + + @Suppress("ReturnCount") + private fun setName( + binding: FileDetailsShareLinkShareItemBinding?, + context: Context?, + publicShare: OCShare, + position: Int + ) { + if (binding == null || context == null) { + return + } + + if (ShareType.PUBLIC_LINK == publicShare.shareType) { + val label = publicShare.label + binding.name.text = when { + label.isNullOrBlank() && position == 0 -> + context.getString(R.string.share_link) + + label.isNullOrBlank() -> + context.getString(R.string.share_link_with_label, position.toString()) + + else -> + context.getString(R.string.share_link_with_label, label) + } + return + } + + if (ShareType.EMAIL == publicShare.shareType) { + binding.name.text = publicShare.sharedWithDisplayName + + val emailDrawable = ResourcesCompat.getDrawable(context.resources, R.drawable.ic_email, null) + binding.icon.setImageDrawable(emailDrawable) + binding.copyLink.visibility = View.GONE + return + } + + val label = publicShare.label + if (!label.isNullOrEmpty()) { + binding.name.text = context.getString(R.string.share_link_with_label, label) + } + } + + @Suppress("ReturnCount") + private fun setSubline(binding: FileDetailsShareLinkShareItemBinding?, context: Context?, publicShare: OCShare) { + if (binding == null || context == null) { + return + } + + val downloadLimit = publicShare.fileDownloadLimit + if (downloadLimit != null) { + val remaining = publicShare.remainingDownloadLimit() ?: return + val text = context.resources.getQuantityString( + R.plurals.share_download_limit_description, + remaining, + remaining + ) + + binding.subline.text = text + binding.subline.visibility = View.VISIBLE + return + } + + binding.subline.visibility = View.GONE + } + + private fun setPermissionName( + binding: FileDetailsShareLinkShareItemBinding?, + context: Context?, + publicShare: OCShare?, + quickPermissionType: QuickPermissionType + ) { + if (binding == null || context == null) { + return + } + + val permissionName = quickPermissionType.getText(context) + + if (TextUtils.isEmpty(permissionName) || (isSecureFileDrop(publicShare) && encrypted)) { + binding.permissionName.visibility = View.GONE + } else { + binding.permissionName.visibility = View.VISIBLE + binding.permissionName.text = permissionName + } + + viewThemeUtils?.androidx?.colorPrimaryTextViewElement(binding.permissionName) + } + + private fun setOnClickListeners( + binding: FileDetailsShareLinkShareItemBinding?, + listener: ShareeListAdapterListener, + publicShare: OCShare + ) { + if (binding == null) { + return + } + + viewThemeUtils?.platform?.colorImageViewBackgroundAndIcon(binding.icon) + + binding.overflowMenu.setOnClickListener { + listener.showSharingMenuActionSheet(publicShare) + } + binding.shareByLinkContainer.setOnClickListener { + listener.showPermissionsDialog(publicShare) + } + } + + private fun configureCopyLink( + binding: FileDetailsShareLinkShareItemBinding?, + context: Context?, + listener: ShareeListAdapterListener, + publicShare: OCShare + ) { + if (binding == null || context == null) { + return + } + + if (MDMConfig.clipBoardSupport(context)) { + binding.copyLink.setOnClickListener { v: View? -> listener.copyLink(publicShare) } + } else { + binding.copyLink.setVisibility(View.GONE) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt new file mode 100644 index 000000000000..bbbfc9ade682 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.widget.TextView + +internal interface ListGridItemViewHolder : ListViewHolder { + val fileName: TextView + val extension: TextView? +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ListItemViewHolder.kt new file mode 100644 index 000000000000..6d969b6a9790 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ListItemViewHolder.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.owncloud.android.ui.AvatarGroupLayout + +internal interface ListItemViewHolder : ListGridItemViewHolder { + val fileSize: TextView + val fileSizeSeparator: View + val lastModification: TextView + val overflowMenu: ImageView + val sharedAvatars: AvatarGroupLayout + val tagsGroup: ChipGroup + val firstTag: Chip + val secondTag: Chip + val tagMore: Chip + val fileDetailGroup: LinearLayout +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ListViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ListViewHolder.kt new file mode 100644 index 000000000000..cceefd293dbf --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ListViewHolder.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.elyeproj.loaderviewlibrary.LoaderImageView + +interface ListViewHolder { + val thumbnail: ImageView + fun showVideoOverlay() + val shimmerThumbnail: LoaderImageView + val favorite: ImageView + val localFileIndicator: ImageView + val imageFileName: TextView? + val shared: ImageView + val checkbox: ImageView + val itemLayout: View + val unreadComments: ImageView + val more: ImageButton? + val fileFeaturesLayout: LinearLayout? + val gridLivePhotoIndicator: ImageView? + val livePhotoIndicator: TextView? + val livePhotoIndicatorSeparator: TextView? + val hasVisibleFeatureIndicators: Boolean +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java new file mode 100644 index 000000000000..676f911e6f49 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java @@ -0,0 +1,603 @@ +/* + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2018 Tobias Kaminsky + * Copyright (C) 2018 Nextcloud + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.utils.FileHelper; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.adapter.storagePermissionBanner.StoragePermissionBannerViewHolder; +import com.owncloud.android.ui.interfaces.LocalFileListFragmentInterface; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.FileSortOrder; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.PermissionUtil; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +/** + * This Adapter populates a {@link RecyclerView} with all files and directories contained in a local directory + */ +public class LocalFileListAdapter extends RecyclerView.Adapter implements FilterableListAdapter { + + private static final String TAG = LocalFileListAdapter.class.getSimpleName(); + + private final AppPreferences preferences; + private final Activity mContext; + private List mFiles = new ArrayList<>(); + private List mFilesAll = new ArrayList<>(); + private boolean mLocalFolderPicker; + private boolean gridView = false; + private LocalFileListFragmentInterface localFileListFragmentInterface; + private Set checkedFiles; + private ViewThemeUtils viewThemeUtils; + private boolean isWithinEncryptedFolder; + private final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); + + private static final int VIEWTYPE_ITEM = 0; + private static final int VIEWTYPE_FOOTER = 1; + private static final int VIEWTYPE_IMAGE = 2; + private static final int VIEWTYPE_HEADER = 3; + + private static final int PAGE_SIZE = 50; + private int currentOffset = 0; + + public LocalFileListAdapter(boolean localFolderPickerMode, + LocalFileListFragmentInterface localFileListFragmentInterface, + AppPreferences preferences, + Activity context, + final ViewThemeUtils viewThemeUtils, + boolean isWithinEncryptedFolder) { + this.preferences = preferences; + mContext = context; + mLocalFolderPicker = localFolderPickerMode; + this.localFileListFragmentInterface = localFileListFragmentInterface; + checkedFiles = new HashSet<>(); + this.viewThemeUtils = viewThemeUtils; + this.isWithinEncryptedFolder = isWithinEncryptedFolder; + setHasStableIds(true); + } + + public int getFilesCount() { + return mFiles.size(); + } + + public boolean isCheckedFile(File file) { + return checkedFiles.contains(file); + } + + public void removeCheckedFile(File file) { + checkedFiles.remove(file); + } + + public void addCheckedFile(File file) { + checkedFiles.add(file); + } + + public void addAllFilesToCheckedFiles() { + if (isWithinEncryptedFolder) { + for (File file : mFilesAll) { + if (file.isFile()) { + checkedFiles.add(file); + } + } + } else { + checkedFiles.addAll(mFiles); + } + } + + public void removeAllFilesFromCheckedFiles() { + checkedFiles.clear(); + } + + public int getItemPosition(File file) { + return mFiles.indexOf(file); + } + + public String[] getCheckedFilesPath() { + List result = FileHelper.INSTANCE.listFilesRecursive(checkedFiles); + + Log_OC.d(TAG, "Returning " + result.size() + " selected files"); + + return result.toArray(new String[0]); + } + + @Override + public int getItemCount() { + // footer always exists + int count = mFiles.size() + 1; + + // check header section visibility + if (shouldShowHeader()) { + count += 1; + } + + return count; + } + + @Override + public long getItemId(int position) { + if (position >= mFiles.size()) { + return RecyclerView.NO_ID; + } + + File file = mFiles.get(position); + return file.getAbsolutePath().hashCode(); + } + + @Override + public int getItemViewType(int position) { + boolean header = shouldShowHeader(); + int headerOffset = header ? 1 : 0; + + if (header && position == 0) { + return VIEWTYPE_HEADER; + } + + // footer position + if (position == mFiles.size() + headerOffset) { + return VIEWTYPE_FOOTER; + } + + // real file position + int fileIndex = position - headerOffset; + File file = mFiles.get(fileIndex); + + if (MimeTypeUtil.isImageOrVideo(file)) { + return VIEWTYPE_IMAGE; + } else { + return VIEWTYPE_ITEM; + } + } + + private boolean shouldShowHeader() { + return !PermissionUtil.checkStoragePermission(mContext); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + boolean headerVisible = shouldShowHeader(); + int headerOffset = headerVisible ? 1 : 0; + + // --- HEADER --- + if (headerVisible && position == 0) { + // Header has no dynamic binding + return; + } + + // --- FOOTER --- + if (position == mFiles.size() + headerOffset) { + LocalFileListFooterViewHolder footer = (LocalFileListFooterViewHolder) holder; + footer.footerText.setText(getFooterText()); + return; + } + + int fileIndex = position - headerOffset; + if (fileIndex < 0 || fileIndex >= mFiles.size()) { + return; + } + + File file = mFiles.get(fileIndex); + if (file == null) { + return; + } + + LocalFileListGridItemViewHolder grid = (LocalFileListGridItemViewHolder) holder; + + // Background + checkbox logic + if (mLocalFolderPicker) { + grid.itemLayout.setBackgroundColor(mContext.getResources().getColor(R.color.bg_default)); + grid.checkbox.setVisibility(View.GONE); + } else { + grid.checkbox.setVisibility(View.VISIBLE); + + if (isCheckedFile(file)) { + grid.itemLayout.setBackgroundColor( + ContextCompat.getColor(mContext, R.color.selected_item_background) + ); + grid.checkbox.setImageDrawable( + viewThemeUtils.platform.tintDrawable(mContext, R.drawable.ic_checkbox_marked, ColorRole.PRIMARY) + ); + } else { + grid.itemLayout.setBackgroundColor( + mContext.getResources().getColor(R.color.bg_default) + ); + grid.checkbox.setImageResource(R.drawable.ic_checkbox_blank_outline); + } + + grid.checkbox.setOnClickListener(v -> + localFileListFragmentInterface.onItemCheckboxClicked(file) + ); + } + + // Thumbnail + grid.thumbnail.setTag(file.hashCode()); + setThumbnail(file, grid.thumbnail, mContext, viewThemeUtils); + + grid.itemLayout.setOnClickListener(v -> + localFileListFragmentInterface.onItemClicked(file) + ); + + if (holder instanceof LocalFileListItemViewHolder item) { + if (file.isDirectory()) { + item.fileSize.setVisibility(View.GONE); + item.fileSeparator.setVisibility(View.GONE); + + if (isWithinEncryptedFolder) { + item.checkbox.setVisibility(View.GONE); + } + + } else { + item.fileSize.setVisibility(View.VISIBLE); + item.fileSeparator.setVisibility(View.VISIBLE); + item.fileSize.setText(DisplayUtils.bytesToHumanReadable(file.length())); + } + + item.lastModification.setText( + DisplayUtils.getRelativeTimestamp(mContext, file.lastModified()) + ); + } + + // Filename + grid.fileName.setText(file.getName()); + } + + public static void setThumbnail(File file, + ImageView thumbnailView, + Context context, + ViewThemeUtils viewThemeUtils) { + if (file.isDirectory()) { + thumbnailView.setImageDrawable(MimeTypeUtil.getDefaultFolderIcon(context, viewThemeUtils)); + } else { + thumbnailView.setImageResource(R.drawable.file); + + /* Cancellation needs do be checked and done before changing the drawable in fileIcon, or + * {@link ThumbnailsCacheManager#cancelPotentialThumbnailWork} will NEVER cancel any task. + */ + boolean allowedToCreateNewThumbnail = ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, + thumbnailView); + + + // get Thumbnail if file is image + if (MimeTypeUtil.isImage(file)) { + // Thumbnail in Cache? + Bitmap thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.hashCode() + ); + if (thumbnail != null) { + thumbnailView.setImageBitmap(thumbnail); + } else { + + // generate new Thumbnail + if (allowedToCreateNewThumbnail) { + final ThumbnailsCacheManager.ThumbnailGenerationTask task = + new ThumbnailsCacheManager.ThumbnailGenerationTask(thumbnailView); + if (MimeTypeUtil.isVideo(file)) { + thumbnail = ThumbnailsCacheManager.mDefaultVideo; + } else { + thumbnail = ThumbnailsCacheManager.mDefaultImg; + } + final ThumbnailsCacheManager.AsyncThumbnailDrawable asyncDrawable = + new ThumbnailsCacheManager.AsyncThumbnailDrawable( + context.getResources(), + thumbnail, + task + ); + thumbnailView.setImageDrawable(asyncDrawable); + task.execute(new ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, null)); + Log_OC.v(TAG, "Executing task to generate a new thumbnail"); + + } // else, already being generated, don't restart it + } + } else { + thumbnailView.setImageDrawable(MimeTypeUtil.getFileTypeIcon(null, + file.getName(), + context, + viewThemeUtils)); + } + } + } + + private File getItem(int position) { + return mFiles.get(position); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case VIEWTYPE_ITEM, VIEWTYPE_IMAGE: + if (gridView) { + View itemView = LayoutInflater.from(mContext).inflate(R.layout.grid_item, parent, false); + return new LocalFileListGridItemViewHolder(itemView); + } else { + View itemView = LayoutInflater.from(mContext).inflate(R.layout.list_item, parent, false); + return new LocalFileListItemViewHolder(itemView); + } + + case VIEWTYPE_FOOTER: + View itemView = LayoutInflater.from(mContext).inflate(R.layout.list_footer, parent, false); + return new LocalFileListFooterViewHolder(itemView); + + case VIEWTYPE_HEADER: + View headerItemView = LayoutInflater.from(mContext).inflate(R.layout.storage_permission_warning_banner, parent, false); + return new StoragePermissionBannerViewHolder(mContext, headerItemView); + default: + throw new IllegalArgumentException("Invalid viewType: " + viewType); + } + } + + /** + * Change the adapted directory for a new one + * + * @param directory New file to adapt. Can be NULL, meaning "no content to adapt". + */ + public void swapDirectory(final File directory) { + localFileListFragmentInterface.setLoading(true); + currentOffset = 0; + + singleThreadExecutor.execute(() -> { + // Load first page of folders + List firstPage = FileHelper.INSTANCE.listDirectoryEntries(directory, currentOffset, PAGE_SIZE, true); + + if (!firstPage.isEmpty()) { + firstPage = sortAndFilterHiddenEntries(firstPage); + } + + currentOffset += PAGE_SIZE; + updateUIForFirstPage(firstPage); + + // Load remaining folders, then all files + loadRemainingEntries(directory, true); + + // Reset for files + currentOffset = 0; + + loadRemainingEntries(directory, false); + }); + } + + @SuppressLint("NotifyDataSetChanged") + private void updateUIForFirstPage(List firstPage) { + new Handler(Looper.getMainLooper()).post(() -> { + mFiles.clear(); + mFilesAll.clear(); + mFiles.addAll(firstPage); + mFilesAll.addAll(firstPage); + notifyDataSetChanged(); + localFileListFragmentInterface.setLoading(false); + }); + } + + private List sortAndFilterHiddenEntries(List nextPage) { + boolean showHiddenFiles = preferences.isShowHiddenFilesEnabled(); + FileSortOrder sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.localFileListView); + + if (!showHiddenFiles) { + nextPage = filterHiddenFiles(nextPage); + } + + return sortOrder.sortLocalFiles(nextPage); + } + + private void loadRemainingEntries(File directory, boolean fetchFolders) { + while (true) { + List nextPage = FileHelper.INSTANCE.listDirectoryEntries(directory, currentOffset, PAGE_SIZE, fetchFolders); + if (nextPage.isEmpty()) { + break; + } + + nextPage = sortAndFilterHiddenEntries(nextPage); + + currentOffset += PAGE_SIZE; + notifyItemRange(nextPage); + } + } + + private void notifyItemRange(List updatedList) { + new Handler(Looper.getMainLooper()).post(() -> { + int headerOffset = shouldShowHeader() ? 1 : 0; + int startPositionInAdapter = mFiles.size() + headerOffset; + int itemCount = updatedList.size(); + + mFiles.addAll(updatedList); + mFilesAll.addAll(updatedList); + + Log_OC.d(TAG, "notifyItemRange, item size: " + mFilesAll.size()); + + notifyItemRangeInserted(startPositionInAdapter, itemCount); + }); + } + + @SuppressLint("NotifyDataSetChanged") + public void setSortOrder(FileSortOrder sortOrder) { + localFileListFragmentInterface.setLoading(true); + singleThreadExecutor.execute(() -> { + List sortedCopy = new ArrayList<>(mFiles); + sortedCopy = sortOrder.sortLocalFiles(sortedCopy); + + final List finalSortedCopy = sortedCopy; + new Handler(Looper.getMainLooper()).post(() -> { + mFiles = finalSortedCopy; + mFilesAll = new ArrayList<>(finalSortedCopy); + notifyDataSetChanged(); + localFileListFragmentInterface.setLoading(false); + }); + }); + } + + @SuppressLint("NotifyDataSetChanged") + public void filter(String text) { + if (text.isEmpty()) { + mFiles = mFilesAll; + } else { + List result = new ArrayList<>(); + String filterText = text.toLowerCase(Locale.getDefault()); + for (File file : mFilesAll) { + if (file.getName().toLowerCase(Locale.getDefault()).contains(filterText)) { + result.add(file); + } + } + mFiles = result; + } + notifyDataSetChanged(); + } + + /** + * Filter for hidden files + * + * @param files ArrayList of files to filter + * @return Non-hidden files + */ + private List filterHiddenFiles(List files) { + List ret = new ArrayList<>(); + + for (File file : files) { + if (!file.isHidden()) { + ret.add(file); + } + } + return ret; + } + + private String getFooterText() { + int filesCount = 0; + int foldersCount = 0; + + for (File file : mFiles) { + if (file.isDirectory()) { + foldersCount++; + } else { + if (!file.isHidden()) { + filesCount++; + } + } + } + + return generateFooterText(filesCount, foldersCount); + } + + private String generateFooterText(int filesCount, int foldersCount) { + String output; + Resources resources = mContext.getResources(); + + if (filesCount + foldersCount <= 0) { + output = ""; + } else if (foldersCount <= 0) { + output = resources.getQuantityString(R.plurals.file_list__footer__file, filesCount, filesCount); + } else if (filesCount <= 0) { + output = resources.getQuantityString(R.plurals.file_list__footer__folder, foldersCount, foldersCount); + } else { + output = resources.getQuantityString(R.plurals.file_list__footer__file, filesCount, filesCount) + ", " + + resources.getQuantityString(R.plurals.file_list__footer__folder, foldersCount, foldersCount); + } + + return output; + } + + public void setGridView(boolean gridView) { + this.gridView = gridView; + } + + public int checkedFilesCount() { + return checkedFiles.size(); + } + + private static class LocalFileListItemViewHolder extends LocalFileListGridItemViewHolder { + private final TextView fileSize; + private final TextView lastModification; + private final TextView fileSeparator; + + private LocalFileListItemViewHolder(View itemView) { + super(itemView); + + fileSize = itemView.findViewById(R.id.file_size); + fileSeparator = itemView.findViewById(R.id.file_separator); + lastModification = itemView.findViewById(R.id.last_mod); + + itemView.findViewById(R.id.sharedAvatars).setVisibility(View.GONE); + itemView.findViewById(R.id.overflow_menu).setVisibility(View.GONE); + itemView.findViewById(R.id.tagsGroup).setVisibility(View.GONE); + } + } + + private static class LocalFileListGridItemViewHolder extends RecyclerView.ViewHolder { + protected final TextView fileName; + protected final ImageView thumbnail; + protected final ImageView checkbox; + protected final LinearLayout itemLayout; + + private LocalFileListGridItemViewHolder(View itemView) { + super(itemView); + + fileName = itemView.findViewById(R.id.Filename); + thumbnail = itemView.findViewById(R.id.thumbnail); + checkbox = itemView.findViewById(R.id.custom_checkbox); + itemLayout = itemView.findViewById(R.id.ListItemLayout); + + itemView.findViewById(R.id.sharedIcon).setVisibility(View.GONE); + itemView.findViewById(R.id.favorite_action).setVisibility(View.GONE); + itemView.findViewById(R.id.localFileIndicator).setVisibility(View.GONE); + } + } + + private static class LocalFileListFooterViewHolder extends RecyclerView.ViewHolder { + private final TextView footerText; + + private LocalFileListFooterViewHolder(View itemView) { + super(itemView); + + footerText = itemView.findViewById(R.id.footerText); + } + } + + @SuppressLint("NotifyDataSetChanged") + @VisibleForTesting + public void setFiles(List newFiles) { + mFiles = newFiles; + mFilesAll = new ArrayList<>(mFiles); + + notifyDataSetChanged(); + localFileListFragmentInterface.setLoading(false); + } + + public void cleanup() { + singleThreadExecutor.shutdown(); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/NewLinkShareViewHolder.java b/app/src/main/java/com/owncloud/android/ui/adapter/NewLinkShareViewHolder.java new file mode 100644 index 000000000000..c842ba0eee3c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/NewLinkShareViewHolder.java @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import android.view.View; + +import com.owncloud.android.databinding.FileDetailsSharePublicLinkAddNewItemBinding; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +class NewLinkShareViewHolder extends RecyclerView.ViewHolder { + private FileDetailsSharePublicLinkAddNewItemBinding binding; + + public NewLinkShareViewHolder(@NonNull View itemView) { + super(itemView); + } + + public NewLinkShareViewHolder(FileDetailsSharePublicLinkAddNewItemBinding binding) { + this(binding.getRoot()); + this.binding = binding; + } + + public void bind(ShareeListAdapterListener listener) { + binding.addNewPublicShareLink.setOnClickListener(v -> listener.createPublicShareLink()); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/NewSecureFileDropViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/NewSecureFileDropViewHolder.kt new file mode 100644 index 000000000000..0e2746288561 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/NewSecureFileDropViewHolder.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.databinding.FileDetailsShareSecureFileDropAddNewItemBinding +import com.owncloud.android.utils.theme.ViewThemeUtils + +class NewSecureFileDropViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private var binding: FileDetailsShareSecureFileDropAddNewItemBinding? = null + private var viewThemeUtils: ViewThemeUtils? = null + + constructor( + binding: FileDetailsShareSecureFileDropAddNewItemBinding, + viewThemeUtils: ViewThemeUtils + ) : this(binding.root) { + this.binding = binding + this.viewThemeUtils = viewThemeUtils + } + + fun bind(listener: ShareeListAdapterListener) { + binding?.addPublicShare?.let { + it.setOnClickListener { listener.createSecureFileDrop() } + viewThemeUtils?.material?.colorMaterialButtonPrimaryTonal(it) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/NotificationListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/NotificationListAdapter.kt new file mode 100644 index 000000000000..00677da4fa82 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/NotificationListAdapter.kt @@ -0,0 +1,336 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2018-2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2017 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.net.toUri +import androidx.core.view.size +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.R +import com.owncloud.android.databinding.NotificationListItemBinding +import com.owncloud.android.lib.resources.notifications.models.Action +import com.owncloud.android.lib.resources.notifications.models.Notification +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.fragment.notifications.NotificationsAdapterItemClick +import com.owncloud.android.ui.fragment.notifications.NotificationsFragment +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils + +@Suppress("TooManyFunctions") +class NotificationListAdapter( + private val fragment: NotificationsFragment, + private val viewThemeUtils: ViewThemeUtils, + private val itemClick: NotificationsAdapterItemClick +) : RecyclerView.Adapter() { + + private val styleSpanBold = StyleSpan(Typeface.BOLD) + private val foregroundColorSpanBlack = ForegroundColorSpan( + ContextCompat.getColor(fragment.requireContext(), R.color.text_color) + ) + private val notificationsList = ArrayList() + + // region Adapter overrides + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = NotificationViewHolder( + NotificationListItemBinding.inflate( + LayoutInflater.from(fragment.requireContext()), + parent, + false + ) + ) + + override fun getItemCount() = notificationsList.size + + override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) { + val notification = notificationsList[position] + bindDateTime(holder, notification) + bindSubject(holder, notification) + bindMessage(holder, notification) + bindIcon(holder, notification) + colorViewHolder(holder) + bindButtons(holder, notification) + } + + // endregion + + // region Bind helpers + + private fun bindDateTime(holder: NotificationViewHolder, notification: Notification) { + val timestamp = DisplayUtils.getRelativeTimestamp( + fragment.requireContext(), + notification.getDatetime().time + ) + holder.binding.datetime.text = timestamp + } + + private fun bindSubject(holder: NotificationViewHolder, notification: Notification) { + val file = notification.subjectRichParameters[FILE] + if (file == null && !TextUtils.isEmpty(notification.getLink())) { + val subject = "${notification.getSubject()} ↗" + holder.binding.subject.run { + setTypeface(typeface, Typeface.BOLD) + text = subject + setOnClickListener { + DisplayUtils.startLinkIntent(fragment.requireActivity(), notification.getLink()) + } + } + } else { + holder.binding.subject.run { + text = if (!TextUtils.isEmpty(notification.subjectRich)) { + makeSpecialPartsBold(notification) + } else { + notification.getSubject() + } + if (file?.id?.isNotEmpty() == true) { + setOnClickListener { + val intent = Intent(fragment.requireActivity(), FileDisplayActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(FileDisplayActivity.KEY_FILE_ID, file.id) + } + fragment.requireActivity().startActivity(intent) + } + } + } + } + } + + private fun bindMessage(holder: NotificationViewHolder, notification: Notification) { + val message = notification.getMessage() + holder.binding.message.run { + if (!message.isNullOrEmpty()) { + text = message + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + } + + private fun bindIcon(holder: NotificationViewHolder, notification: Notification) { + if (notification.getIcon().isNullOrEmpty()) return + itemClick.onBindIcon(holder.binding.icon, notification.getIcon()) + } + + private fun colorViewHolder(holder: NotificationViewHolder) { + viewThemeUtils.platform.run { + colorImageView(holder.binding.icon, ColorRole.ON_SURFACE_VARIANT) + colorImageView(holder.binding.dismiss, ColorRole.ON_SURFACE_VARIANT) + colorTextView(holder.binding.subject, ColorRole.ON_SURFACE) + colorTextView(holder.binding.message, ColorRole.ON_SURFACE_VARIANT) + colorTextView(holder.binding.datetime, ColorRole.ON_SURFACE_VARIANT) + } + } + + // endregion + + // region Button binding + fun bindButtons(holder: NotificationViewHolder, notification: Notification) { + holder.binding.dismiss.setOnClickListener { + itemClick.deleteNotification(notification.notificationId) + } + + val actions = notification.getActions() + holder.binding.buttons.run { + removeAllViews() + setVisibleIf(actions.isNotEmpty()) + } + + if (actions.isEmpty()) { + return + } + + val params = buttonLayoutParams() + + if (actions.size > 2) { + val overflowActions = ArrayList() + for (action in actions) { + if (action.primary) { + addPrimaryButton(holder, action, notification, params) + } else { + overflowActions.add(action) + } + } + val moreButton = + buildButton(transparent = true, label = fragment.getString(R.string.more), params = params) { + showOverflowMenu(it, overflowActions, holder, notification) + } + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(moreButton) + holder.binding.buttons.addView(moreButton) + } else { + for (action in actions) { + val button = buildButton(transparent = !action.primary, label = action.label, params = params) { + onActionClicked(holder, action, notification) + } + if (action.primary) { + viewThemeUtils.material.colorMaterialButtonPrimaryFilled(button) + } else { + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(button) + } + holder.binding.buttons.addView(button) + } + } + } + + private fun addPrimaryButton( + holder: NotificationViewHolder, + action: Action, + notification: Notification, + params: LinearLayout.LayoutParams + ) { + val button = buildButton(transparent = false, label = action.label, params = params) { + onActionClicked(holder, action, notification) + } + viewThemeUtils.material.colorMaterialButtonPrimaryFilled(button) + holder.binding.buttons.addView(button) + } + + private fun buildButton( + transparent: Boolean, + label: String?, + params: LinearLayout.LayoutParams, + onClick: (View) -> Unit + ): MaterialButton = MaterialButton(fragment.requireContext()).apply { + if (transparent) { + setBackgroundColor(ResourcesCompat.getColor(resources, android.R.color.transparent, null)) + } + setAllCaps(false) + text = label + setCornerRadiusResource(R.dimen.button_corner_radius) + layoutParams = params + setGravity(Gravity.CENTER) + setOnClickListener(onClick) + } + + private fun showOverflowMenu( + anchor: View, + overflowActions: List, + holder: NotificationViewHolder, + notification: Notification + ) { + PopupMenu(fragment.requireContext(), anchor).apply { + for (action in overflowActions) { + menu.add(action.label).setOnMenuItemClickListener { + onActionClicked(holder, action, notification) + true + } + } + show() + } + } + + private fun onActionClicked(holder: NotificationViewHolder, action: Action, notification: Notification) { + setButtonEnabled(holder, false) + if (ACTION_TYPE_WEB == action.type) { + fragment.requireActivity().startActivity( + Intent(Intent.ACTION_VIEW).apply { data = action.link?.toUri() } + ) + } else { + itemClick.onActionClick(holder, action, notification) + } + } + + private fun buttonLayoutParams() = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + val resources = fragment.resources + setMargins( + resources.getDimensionPixelOffset(R.dimen.standard_quarter_margin), + 0, + resources.getDimensionPixelOffset(R.dimen.standard_half_margin), + 0 + ) + } + + // endregion + + // region Data manipulation + + @SuppressLint("NotifyDataSetChanged") + fun setNotificationItems(notificationItems: List) { + notificationsList.clear() + notificationsList.addAll(notificationItems) + notifyDataSetChanged() + } + + fun removeNotification(holder: NotificationViewHolder) { + val position = holder.bindingAdapterPosition + if (position in 0 until notificationsList.size) { + notificationsList.removeAt(position) + notifyItemRemoved(position) + notifyItemRangeChanged(position, notificationsList.size) + } + } + + @SuppressLint("NotifyDataSetChanged") + fun removeAllNotifications() { + notificationsList.clear() + notifyDataSetChanged() + } + + fun setButtonEnabled(holder: NotificationViewHolder, enabled: Boolean) { + for (i in 0 until holder.binding.buttons.size) { + holder.binding.buttons.getChildAt(i).isEnabled = enabled + } + } + + // endregion + + private fun makeSpecialPartsBold(notification: Notification): SpannableStringBuilder { + var text = notification.getSubjectRich() + val ssb = SpannableStringBuilder(text) + + var openingBrace = text.indexOf('{') + var closingBrace: Int + var replaceablePart: String? + while (openingBrace != -1) { + closingBrace = text.indexOf('}', openingBrace) + 1 + replaceablePart = text.substring(openingBrace + 1, closingBrace - 1) + notification.subjectRichParameters[replaceablePart]?.name?.let { name -> + ssb.replace(openingBrace, closingBrace, name) + text = ssb.toString() + closingBrace = openingBrace + name.length + + ssb.setSpan(styleSpanBold, openingBrace, closingBrace, 0) + ssb.setSpan(foregroundColorSpanBlack, openingBrace, closingBrace, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + openingBrace = text.indexOf('{', closingBrace) + } + + return ssb + } + + class NotificationViewHolder(var binding: NotificationListItemBinding) : + RecyclerView.ViewHolder(binding.root) + + companion object { + private const val FILE = "file" + private const val ACTION_TYPE_WEB = "WEB" + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java new file mode 100644 index 000000000000..4e0c11632045 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java @@ -0,0 +1,1108 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import android.accounts.AccountManager; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.elyeproj.loaderviewlibrary.LoaderImageView; +import com.google.android.material.chip.Chip; +import com.nextcloud.android.common.core.utils.ecosystem.EcosystemApp; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.nextcloud.client.account.User; +import com.nextcloud.client.database.entity.OfflineOperationEntity; +import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.model.OfflineOperationType; +import com.nextcloud.utils.extensions.ViewExtensionsKt; +import com.nextcloud.utils.mdm.MDMConfig; +import com.owncloud.android.MainApp; +import com.owncloud.android.R; +import com.owncloud.android.databinding.GridItemBinding; +import com.owncloud.android.databinding.ListFooterBinding; +import com.owncloud.android.databinding.ListHeaderBinding; +import com.owncloud.android.databinding.ListItemBinding; +import com.owncloud.android.datamodel.FileDataStorageManager; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.OCFileListAdapterDataProviderImpl; +import com.owncloud.android.datamodel.SyncedFolderProvider; +import com.owncloud.android.datamodel.ThumbnailsCacheManager; +import com.owncloud.android.datamodel.VirtualFolderType; +import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.lib.resources.shares.ShareeUser; +import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.lib.resources.tags.Tag; +import com.owncloud.android.ui.activity.ComponentsGetter; +import com.owncloud.android.ui.activity.DrawerActivity; +import com.owncloud.android.ui.activity.FileDisplayActivity; +import com.owncloud.android.ui.adapter.helper.OCFileListAdapterDataProvider; +import com.owncloud.android.ui.adapter.helper.OCFileListAdapterHelper; +import com.owncloud.android.ui.fragment.OCFileListFragment; +import com.owncloud.android.ui.fragment.SearchType; +import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface; +import com.owncloud.android.ui.preview.PreviewTextFragment; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.FileSortOrder; +import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.overlay.OverlayManager; +import com.owncloud.android.utils.theme.CapabilityUtils; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import kotlin.Pair; +import kotlin.Unit; +import me.zhanghai.android.fastscroll.PopupTextProvider; + +/** + * This Adapter populates a RecyclerView with all files and folders in a Nextcloud instance. + */ +@SuppressWarnings("unchecked") +public class OCFileListAdapter extends RecyclerView.Adapter + implements DisplayUtils.AvatarGenerationListener, + CommonOCFileListAdapterInterface, PopupTextProvider { + + private final String userId; + private final Activity activity; + private final AppPreferences preferences; + private final OCCapability capability; + private List mFiles = new ArrayList<>(); + private final List mFilesAll = new ArrayList<>(); + private final boolean hideItemOptions; + private boolean gridView; + public ArrayList listOfHiddenFiles = new ArrayList<>(); + private FileDataStorageManager mStorageManager; + private OCFileListAdapterDataProvider adapterDataProvider; + private User user; + private final OCFileListFragmentInterface ocFileListFragmentInterface; + private final boolean isRTL; + + private OCFile currentDirectory; + private static final String TAG = OCFileListAdapter.class.getSimpleName(); + + private static final int VIEW_TYPE_FOOTER = 0; + private static final int VIEW_TYPE_ITEM = 1; + private static final int VIEW_TYPE_IMAGE = 2; + private static final int VIEW_TYPE_HEADER = 3; + + private boolean onlyOnDevice; + private final OCFileListDelegate ocFileListDelegate; + private FileSortOrder sortOrder; + + private final SimpleDateFormat dateFormat = new SimpleDateFormat("MMMM yyyy", Locale.getDefault()); + private final ViewThemeUtils viewThemeUtils; + private SearchType searchType; + + private final long footerId = UUID.randomUUID().getLeastSignificantBits(); + private final long headerId = UUID.randomUUID().getLeastSignificantBits(); + + private List recommendedFiles = new ArrayList<>(); + private RecommendedFilesAdapter recommendedFilesAdapter; + private final OCFileListAdapterHelper helper = new OCFileListAdapterHelper(); + private final OverlayManager overlayManager; + + public OCFileListAdapter( + Activity activity, + @NonNull User user, + AppPreferences preferences, + SyncedFolderProvider syncedFolderProvider, + ComponentsGetter transferServiceGetter, + OCFileListFragmentInterface ocFileListFragmentInterface, + boolean argHideItemOptions, + boolean gridView, + final ViewThemeUtils viewThemeUtils, + OverlayManager overlayManager) { + this.overlayManager = overlayManager; + this.ocFileListFragmentInterface = ocFileListFragmentInterface; + this.activity = activity; + this.preferences = preferences; + this.user = user; + hideItemOptions = argHideItemOptions; + this.gridView = gridView; + mStorageManager = transferServiceGetter.getStorageManager(); + this.capability = CapabilityUtils.getCapability(user, activity); + + if (activity instanceof FileDisplayActivity) { + ((FileDisplayActivity) activity).showSortListGroup(true); + } + + if (mStorageManager == null) { + mStorageManager = new FileDataStorageManager(user, activity.getContentResolver()); + } + + adapterDataProvider = new OCFileListAdapterDataProviderImpl(mStorageManager); + + userId = AccountManager + .get(activity) + .getUserData(this.user.toPlatformAccount(), + AccountUtils.Constants.KEY_USER_ID); + this.viewThemeUtils = viewThemeUtils; + ocFileListDelegate = new OCFileListDelegate(FileUploadHelper.Companion.instance(), + activity, + ocFileListFragmentInterface, + user, + mStorageManager, + hideItemOptions, + preferences, + gridView, + transferServiceGetter, + true, + true, + viewThemeUtils, + syncedFolderProvider); + + setHasStableIds(true); + + // initialise thumbnails cache on background thread + ThumbnailsCacheManager.initDiskCacheAsync(); + isRTL = DisplayUtils.isRTL(); + } + + public boolean isMultiSelect() { + return ocFileListDelegate.isMultiSelect(); + } + + @SuppressLint("NotifyDataSetChanged") + public void setMultiSelect(boolean bool) { + ocFileListDelegate.setMultiSelect(bool); + notifyDataSetChanged(); + } + + public void removeCheckedFile(@NonNull OCFile file) { + ocFileListDelegate.removeCheckedFile(file); + } + + @Override + public void selectAll(boolean value) { + if (value) { + ocFileListDelegate.addToCheckedFiles(mFiles); + } else { + clearCheckedItems(); + } + } + + public int getItemPosition(@NonNull OCFile file) { + int position = mFiles.indexOf(file); + + if (shouldShowHeader()) { + position = position + 1; + } + + return position; + } + + @SuppressLint("NotifyDataSetChanged") + public void setFavoriteAttributeForItemID(String remotePath, boolean favorite, boolean removeFromList) { + List filesToDelete = new ArrayList<>(); + for (OCFile file : mFiles) { + if (file.getRemotePath().equals(remotePath)) { + file.setFavorite(favorite); + + if (removeFromList) { + filesToDelete.add(file); + } + + break; + } + } + + for (OCFile file : mFilesAll) { + if (file.getRemotePath().equals(remotePath)) { + file.setFavorite(favorite); + + mStorageManager.saveFile(file); + + if (removeFromList) { + filesToDelete.add(file); + } + + break; + } + } + + FileSortOrder sortOrder = preferences.getSortOrderByFolder(currentDirectory); + if (searchType == SearchType.SHARED_FILTER) { + mFiles.sort((o1, o2) -> Long.compare(o2.getFirstShareTimestamp(), o1.getFirstShareTimestamp())); + } else { + boolean foldersBeforeFiles = preferences.isSortFoldersBeforeFiles(); + boolean favoritesFirst = preferences.isSortFavoritesFirst(); + mFiles = sortOrder.sortCloudFiles(mFiles, foldersBeforeFiles, favoritesFirst); + } + + new Handler(Looper.getMainLooper()).post(() -> { + mFiles.removeAll(filesToDelete); + notifyDataSetChanged(); + }); + } + + public void refreshCommentsCount(String fileId) { + for (OCFile file : mFiles) { + if (file.getRemoteId().equals(fileId)) { + file.setUnreadCommentsCount(0); + break; + } + } + + for (OCFile file : mFilesAll) { + if (file.getRemoteId().equals(fileId)) { + file.setUnreadCommentsCount(0); + break; + } + } + + new Handler(Looper.getMainLooper()).post(this::notifyDataSetChanged); + } + + public void updateFileEncryptionById(String fileId, boolean encrypted) { + if (fileId == null) return; + + mFilesAll.stream() + .filter(f -> fileId.equals(f.getRemoteId())) + .findFirst() + .ifPresent(file -> { + file.setEncrypted(encrypted); + file.setE2eCounter(0L); + mStorageManager.saveFile(file); + + int position = getItemPosition(file); + if (position != -1) { + notifyItemChanged(position); + } + }); + } + + @Override + public long getItemId(int position) { + if (shouldShowHeader()) { + if (position == 0) { + return headerId; + } + + + // skip header + position--; + } + + if (position == mFiles.size()) { + return footerId; + } if (position < mFiles.size()) { + return mFiles.get(position).getFileId(); + } + + // fallback + return RecyclerView.NO_ID; + } + + @Override + public int getItemCount() { + return mFiles.size() + (shouldShowHeader() ? 2 : 1); + } + + @Nullable + public OCFile getItem(int position) { + if (mFiles == null || mFiles.isEmpty()) { + return null; + } + + if (position < 0) { + return null; + } + + int newPosition = position; + + if (shouldShowHeader()) { + if (position == 0) { + // Header position — no file here + return null; + } + newPosition = position - 1; + } + + if (newPosition >= mFiles.size()) { + return null; + } + + return mFiles.get(newPosition); + } + + @Override + public int getItemViewType(int position) { + if (shouldShowHeader() && position == 0) { + return VIEW_TYPE_HEADER; + } + + if (shouldShowHeader() && position == mFiles.size() + 1 || + (!shouldShowHeader() && position == mFiles.size())) { + return VIEW_TYPE_FOOTER; + } + + OCFile item = getItem(position); + if (item == null) { + return VIEW_TYPE_ITEM; + } + + if (MimeTypeUtil.isImageOrVideo(item)) { + return VIEW_TYPE_IMAGE; + } else { + return VIEW_TYPE_ITEM; + } + } + + public boolean isEmpty() { + return mFiles.isEmpty(); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case VIEW_TYPE_FOOTER -> { + return new OCFileListFooterViewHolder( + ListFooterBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false) + ); + } + case VIEW_TYPE_HEADER -> { + ListHeaderBinding binding = ListHeaderBinding.inflate( + LayoutInflater.from(parent.getContext()), + parent, + false); + + return new OCFileListHeaderViewHolder(binding); + } + default -> { + if (gridView) { + return new OCFileListGridItemViewHolder( + GridItemBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false) + ); + } else { + return new OCFileListItemViewHolder( + ListItemBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false) + ); + } + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (holder instanceof OCFileListFooterViewHolder footerViewHolder) { + footerViewHolder.getFooterText().setText(getFooterText()); + viewThemeUtils.platform.colorCircularProgressBar(footerViewHolder.getLoadingProgressBar(), ColorRole.ON_SURFACE_VARIANT); + footerViewHolder.getLoadingProgressBar().setVisibility( + ocFileListFragmentInterface.isLoading() ? View.VISIBLE : View.GONE); + } else if (holder instanceof OCFileListHeaderViewHolder headerViewHolder) { + ListHeaderBinding headerBinding = headerViewHolder.getBinding(); + headerViewHolder.getHeaderView().setOnClickListener(v -> ocFileListFragmentInterface.onHeaderClicked()); + + String text = currentDirectory.getRichWorkspace(); + PreviewTextFragment.setText(headerViewHolder.getHeaderText(), text, null, activity, true, true, viewThemeUtils); + + // hide header text if empty (server returns NBSP) + ViewExtensionsKt.setVisibleIf(headerViewHolder.getHeaderText(), text != null && !text.isBlank() && !" ".equals(text)); + + ViewExtensionsKt.setVisibleIf(headerBinding.recommendedFilesRecyclerView, shouldShowRecommendedFiles()); + ViewExtensionsKt.setVisibleIf(headerBinding.recommendedFilesTitle, shouldShowRecommendedFiles()); + ViewExtensionsKt.setVisibleIf(headerBinding.allFilesTitle, shouldShowRecommendedFiles()); + + if (shouldShowRecommendedFiles()) { + final var recommendedFilesRecyclerView = headerBinding.recommendedFilesRecyclerView; + + final LinearLayoutManager layoutManager = new LinearLayoutManager(activity, LinearLayoutManager.HORIZONTAL, false); + recommendedFilesRecyclerView.setLayoutManager(layoutManager); + + recommendedFilesAdapter = new RecommendedFilesAdapter(this, recommendedFiles); + recommendedFilesRecyclerView.setAdapter(recommendedFilesAdapter); + } + + ViewExtensionsKt.setVisibleIf(headerBinding.openIn.getRoot(), shouldShowOpenInNotes()); + + if (shouldShowOpenInNotes()) { + final var listHeaderOpenInBinding = headerBinding.openIn; + + viewThemeUtils.files.themeFilledCardView(listHeaderOpenInBinding.infoCard); + + listHeaderOpenInBinding.infoText.setText(String.format(activity.getString(R.string.folder_best_viewed_in), + activity.getString(R.string.ecosystem_apps_notes))); + + listHeaderOpenInBinding.openInButton.setText(String.format(activity.getString(R.string.open_in_app), + activity.getString(R.string.ecosystem_apps_display_notes))); + + if (activity instanceof DrawerActivity drawerActivity) { + final var ecosystemManager = drawerActivity.getEcosystemManager(); + if (ecosystemManager != null) { + listHeaderOpenInBinding.openInButton.setOnClickListener(v -> ecosystemManager.openApp(EcosystemApp.NOTES, user.getAccountName())); + } + } + } + + } else { + ListViewHolder viewHolder = (ListViewHolder) holder; + OCFile file = getItem(position); + + if (file == null) { + Log_OC.e(this, "Cannot bind on view holder on a null file"); + return; + } + + bindHolder(holder, viewHolder, file); + } + } + + public void bindRecommendedFilesHolder(OCFileListRecommendedItemViewHolder holder, @NonNull OCFile file) { + bindHolder(holder, holder, file); + } + + private void bindHolder(@NonNull RecyclerView.ViewHolder holder, ListViewHolder viewHolder, OCFile file) { + ocFileListDelegate.bindViewHolder(viewHolder, file, currentDirectory, searchType, overlayManager); + + if (holder instanceof ListItemViewHolder itemViewHolder) { + bindListItemViewHolder(itemViewHolder, file); + } + + if (holder instanceof ListGridItemViewHolder gridItemViewHolder) { + setFilenameAndExtension(gridItemViewHolder, file); + } + + updateLivePhotoIndicators(viewHolder, file); + + if (!MDMConfig.INSTANCE.sharingSupport(activity)) { + viewHolder.getShared().setVisibility(View.GONE); + } + + setVisibilityOfMoreOption(viewHolder); + + final var fileFeatureLayout = viewHolder.getFileFeaturesLayout(); + if (fileFeatureLayout != null) { + ViewExtensionsKt.setVisibleIf(fileFeatureLayout, viewHolder.getHasVisibleFeatureIndicators()); + } + } + + private boolean shouldShowRecommendedFiles() { + return !recommendedFiles.isEmpty() && currentDirectory.isRootDirectory(); + } + + private boolean shouldShowOpenInNotes() { + if (!preferences.isShowEcosystemApps()) { + return false; + } + String notesFolderPath = capability.getNotesFolderPath(); + String currentPath = currentDirectory.getDecryptedRemotePath(); + return notesFolderPath != null && currentPath != null && currentPath.startsWith(notesFolderPath); + } + + private void updateLivePhotoIndicators(ListViewHolder holder, OCFile file) { + boolean isLivePhoto = file.getLinkedFileIdForLivePhoto() != null; + + if (holder instanceof OCFileListItemViewHolder) { + holder.getLivePhotoIndicator().setVisibility(isLivePhoto ? (View.VISIBLE) : (View.GONE)); + holder.getLivePhotoIndicatorSeparator().setVisibility(isLivePhoto ? (View.VISIBLE) : (View.GONE)); + } else if (holder instanceof OCFileListViewHolder) { + holder.getGridLivePhotoIndicator().setVisibility(isLivePhoto ? (View.VISIBLE) : (View.GONE)); + } + } + + private void setFilenameAndExtension(ListGridItemViewHolder holder, OCFile file) { + final String filename = mStorageManager.getFilenameConsideringOfflineOperation(file); + final var pair = FileStorageUtils.getFilenameAndExtension(filename, file.isFolder(), isRTL); + final boolean isFolder = file.isFolder(); + + if (holder instanceof OCFileListGridItemViewHolder gridItemViewHolder) { + handleGridMode(filename, gridItemViewHolder, pair, file); + } else { + handleListMode(holder, pair, isFolder); + } + } + + private void handleGridMode(String filename, OCFileListGridItemViewHolder holder, Pair filenamePair, OCFile file) { + boolean containsBidiControlCharacters = FileStorageUtils.containsBidiControlCharacters(filename); + ViewExtensionsKt.setVisibleIf(holder.getFileName(),!containsBidiControlCharacters); + ViewExtensionsKt.setVisibleIf(holder.getBinding().bidiFilenameContainer, containsBidiControlCharacters); + final var extension = holder.getExtension(); + + if (containsBidiControlCharacters) { + holder.getBidiFilename().setText(filenamePair.getFirst()); + if (extension != null) { + extension.setText(filenamePair.getSecond()); + } + holder.getBinding().more.setVisibility(View.GONE); + holder.getBinding().bidiMore.setOnClickListener(v -> ocFileListFragmentInterface.onOverflowIconClicked(file, v)); + } else { + holder.getFileName().setText(filename); + if (extension != null) { + extension.setVisibility(View.GONE); + } + } + } + + private void handleListMode(ListGridItemViewHolder holder, + Pair filenamePair, + boolean isFolder) { + holder.getFileName().setText(filenamePair.getFirst()); + + final var extension = holder.getExtension(); + if (extension != null) { + if (isFolder) { + extension.setVisibility(View.GONE); + } else { + extension.setVisibility(View.VISIBLE); + extension.setText(filenamePair.getSecond()); + } + } + } + + private void bindListItemViewHolder(ListItemViewHolder holder, OCFile file) { + if ((file.isSharedWithMe() || file.isSharedWithSharee()) && !isMultiSelect() && !gridView && + !hideItemOptions) { + holder.getSharedAvatars().setVisibility(View.VISIBLE); + holder.getSharedAvatars().removeAllViews(); + + String fileOwner = file.getOwnerId(); + List sharees = file.getSharees(); + + // use fileOwner if not oneself, then add at first + ShareeUser fileOwnerSharee = new ShareeUser(fileOwner, file.getOwnerDisplayName(), ShareType.USER); + if (!TextUtils.isEmpty(fileOwner) && + !fileOwner.equals(userId) && + !sharees.contains(fileOwnerSharee)) { + sharees.add(fileOwnerSharee); + } + + Collections.reverse(sharees); + + Log_OC.d(this, "sharees of " + file.getFileName() + ": " + sharees); + + holder.getSharedAvatars().setAvatars(user, sharees, viewThemeUtils); + holder.getSharedAvatars().setOnClickListener( + view -> ocFileListFragmentInterface.onShareIconClick(file)); + } else { + holder.getSharedAvatars().setVisibility(View.GONE); + holder.getSharedAvatars().removeAllViews(); + } + + // tags + if (file.getTags().isEmpty()) { + holder.getTagsGroup().setVisibility(View.GONE); + holder.getFileDetailGroup().setVisibility(View.VISIBLE); + } else { + holder.getTagsGroup().setVisibility(View.VISIBLE); + holder.getFileDetailGroup().setVisibility(View.GONE); + viewThemeUtils.material.themeChipSuggestion(holder.getFirstTag()); + holder.getFirstTag().setVisibility(View.VISIBLE); + holder.getSecondTag().setVisibility(View.GONE); + holder.getTagMore().setVisibility(View.GONE); + + applyChipVisuals(holder.getFirstTag(), file.getTags().get(0)); + + if (file.getTags().size() > 1) { + holder.getSecondTag().setVisibility(View.VISIBLE); + applyChipVisuals(holder.getSecondTag(), file.getTags().get(1)); + } + + if (file.getTags().size() > 2) { + viewThemeUtils.material.themeChipSuggestion(holder.getTagMore()); + holder.getTagMore().setVisibility(View.VISIBLE); + holder.getTagMore().setText(String.format(activity.getString(R.string.tags_more), + (file.getTags().size() - 2))); + } + } + + // npe fix: looks like file without local storage path somehow get here + final String storagePath = file.getStoragePath(); + if (onlyOnDevice && storagePath != null) { + File localFile = new File(storagePath); + long localSize; + if (localFile.isDirectory()) { + localSize = FileStorageUtils.getFolderSize(localFile); + } else { + localSize = localFile.length(); + } + + prepareFileSize(holder, file, localSize); + } else { + final long fileLength = file.getFileLength(); + if (fileLength >= 0) { + prepareFileSize(holder, file, fileLength); + } else { + holder.getFileSize().setVisibility(View.GONE); + holder.getFileSizeSeparator().setVisibility(View.GONE); + } + } + + final long modificationTimestamp = file.getModificationTimestamp(); + if (modificationTimestamp > 0) { + holder.getLastModification().setText(DisplayUtils.getRelativeTimestamp(activity, + modificationTimestamp)); + holder.getLastModification().setVisibility(View.VISIBLE); + } else if (file.getFirstShareTimestamp() > 0) { + holder.getLastModification().setText( + DisplayUtils.getRelativeTimestamp(activity, file.getFirstShareTimestamp()) + ); + holder.getLastModification().setVisibility(View.VISIBLE); + } else { + holder.getLastModification().setVisibility(View.GONE); + } + + if (isMultiSelect() || gridView || hideItemOptions) { + holder.getOverflowMenu().setVisibility(View.GONE); + } else { + holder.getOverflowMenu().setVisibility(View.VISIBLE); + holder.getOverflowMenu().setOnClickListener(view -> ocFileListFragmentInterface + .onOverflowIconClicked(file, view)); + } + + if (file.isLocked()) { + holder.getOverflowMenu().setImageResource(R.drawable.ic_locked_dots_small); + } else { + holder.getOverflowMenu().setImageResource(R.drawable.ic_dots_vertical); + } + } + + private void setVisibilityOfMoreOption(Object holder) { + boolean showMoreOptions = (!isMultiSelect() && !OCFileListFragment.isMultipleFileSelectedForCopyOrMove); + + if (holder instanceof ListItemViewHolder itemViewHolder) { + ViewExtensionsKt.setVisibleIf(itemViewHolder.getOverflowMenu(), showMoreOptions); + } else if (holder instanceof ListViewHolder viewHolder) { + ViewExtensionsKt.setVisibleIf(viewHolder.getMore(), showMoreOptions); + } + } + + @SuppressLint("NotifyDataSetChanged") + public void updateRecommendedFiles(@NonNull List value) { + recommendedFiles.clear(); + recommendedFiles.addAll(value); + notifyDataSetChanged(); + } + + private void applyChipVisuals(Chip chip, Tag tag) { + viewThemeUtils.material.themeChipSuggestion(chip); + chip.setText(tag.getName()); + String tagColor = tag.getColor(); + if (TextUtils.isEmpty(tagColor)) { + return; + } + + try { + int color = Color.parseColor(tagColor); + chip.setChipStrokeColor(ColorStateList.valueOf(color)); + chip.setTextColor(color); + } catch (IllegalArgumentException e) { + Log_OC.d(TAG, "Exception applyChipVisuals: " + e); + } + } + + private void prepareFileSize(ListItemViewHolder holder, OCFile file, long size) { + holder.getFileSize().setVisibility(View.VISIBLE); + String fileSizeText = getFileSizeText(file, size); + holder.getFileSize().setText(fileSizeText); + } + + private String getFileSizeText(OCFile file, long size) { + if (!file.isOfflineOperation()) { + return DisplayUtils.bytesToHumanReadable(size); + } + + OfflineOperationEntity entity = mStorageManager.getOfflineEntityFromOCFile(file); + boolean isRemoveOperation = (entity != null && entity.getType() instanceof OfflineOperationType.RemoveFile); + if (isRemoveOperation) { + return activity.getString(R.string.oc_file_list_adapter_offline_operation_remove_description_text); + } + + return activity.getString(R.string.oc_file_list_adapter_offline_operation_description_text); + } + + @Override + public void onViewAttachedToWindow(@NonNull RecyclerView.ViewHolder holder) { + if (holder instanceof ListViewHolder) { + LoaderImageView thumbnailShimmer = ((ListViewHolder) holder).getShimmerThumbnail(); + if (thumbnailShimmer.getVisibility() == View.VISIBLE) { + thumbnailShimmer.setImageResource(R.drawable.background); + thumbnailShimmer.resetLoader(); + } + } + } + + private String getFooterText() { + int filesCount = 0; + int foldersCount = 0; + int count = mFiles.size(); + OCFile file; + final boolean showHiddenFiles = preferences.isShowHiddenFilesEnabled(); + for (int i = 0; i < count; i++) { + file = mFiles.get(i); + if (file.isFolder()) { + foldersCount++; + } else { + if (!file.isHidden() || showHiddenFiles) { + filesCount++; + } + } + } + + return generateFooterText(filesCount, foldersCount); + } + + private String generateFooterText(int filesCount, int foldersCount) { + String output; + Resources resources = activity.getResources(); + + if (filesCount + foldersCount <= 0) { + output = ""; + } else if (foldersCount <= 0) { + output = resources.getQuantityString(R.plurals.file_list__footer__file, filesCount, filesCount); + } else if (filesCount <= 0) { + output = resources.getQuantityString(R.plurals.file_list__footer__folder, foldersCount, foldersCount); + } else { + output = resources.getQuantityString(R.plurals.file_list__footer__file, filesCount, filesCount) + ", " + + resources.getQuantityString(R.plurals.file_list__footer__folder, foldersCount, foldersCount); + } + + return output; + } + + public boolean shouldShowHeader() { + if (currentDirectory == null) { + return false; + } + + if (MainApp.isOnlyOnDevice()) { + return false; + } + + if (shouldShowRecommendedFiles()) { + return true; + } + + if (shouldShowOpenInNotes()) { + return true; + } + + if (currentDirectory.getRichWorkspace() == null) { + return false; + } + + return !TextUtils.isEmpty(currentDirectory.getRichWorkspace().trim()); + } + + /** + * Change the adapted directory for a new one + * + * @param directory New folder to adapt. Can be NULL, meaning "no content to adapt". + * @param updatedStorageManager Optional updated storage manager; used to replace + * @param limitToMimeType show only files of this mimeType + */ + @SuppressLint("NotifyDataSetChanged") + public void swapDirectory( + @NonNull User account, + @NonNull OCFile directory, + @NonNull FileDataStorageManager updatedStorageManager, + boolean onlyOnDevice, + @NonNull String limitToMimeType) { + + this.onlyOnDevice = onlyOnDevice; + + if (!updatedStorageManager.equals(mStorageManager)) { + mStorageManager = updatedStorageManager; + adapterDataProvider = new OCFileListAdapterDataProviderImpl(mStorageManager); + ocFileListDelegate.setShowShareAvatar(true); + this.user = account; + } + + if (mStorageManager == null) { + updateAdapter(new ArrayList<>(), null); + return; + } + + if (userId == null) { + return; + } + + helper.prepareFileList(directory, + adapterDataProvider, + onlyOnDevice, + limitToMimeType, + preferences, + userId, + (newList, fileSortOrder) -> + { + updateAdapter((List) newList, directory); + return Unit.INSTANCE; + }); + } + + public void updateAdapter(List newFiles, OCFile directory) { + Log_OC.d(TAG, "updating the adapter"); + + mFiles = new ArrayList<>(newFiles); + mFilesAll.clear(); + mFilesAll.addAll(mFiles); + + if (directory != null) { + currentDirectory = directory; + } + + searchType = null; + + activity.runOnUiThread(this::notifyDataSetChanged); + } + + public void prepareForSearchData(FileDataStorageManager storageManager, SearchType searchType) { + initStorageManagerShowShareAvatar(storageManager); + clearSearchData(searchType); + } + + private void initStorageManagerShowShareAvatar(FileDataStorageManager storageManager) { + if (mStorageManager == null) { + mStorageManager = (storageManager != null) + ? storageManager + : new FileDataStorageManager(user, activity.getContentResolver()); + + if (storageManager != null) { + adapterDataProvider = new OCFileListAdapterDataProviderImpl(mStorageManager); + ocFileListDelegate.setShowShareAvatar(true); + } + } + } + + private void clearSearchData(SearchType searchType) { + preferences.setPhotoSearchTimestamp(0); + + VirtualFolderType type = switch (searchType) { + case FAVORITE_SEARCH -> VirtualFolderType.FAVORITE; + case GALLERY_SEARCH -> VirtualFolderType.GALLERY; + default -> VirtualFolderType.NONE; + }; + + if (type != VirtualFolderType.GALLERY) { + mStorageManager.deleteVirtuals(type); + } + } + + public void setSortOrder(FileSortOrder newSortOrder) { + sortOrder = newSortOrder; + } + + @SuppressLint("NotifyDataSetChanged") + public void setSortOrder(@Nullable OCFile folder, @NonNull FileSortOrder sortOrder) { + if (searchType == SearchType.FAVORITE_SEARCH) { + preferences.setSortOrder(FileSortOrder.Type.favoritesListView, sortOrder); + } else { + preferences.setSortOrder(folder, sortOrder); + } + + boolean foldersBeforeFiles = preferences.isSortFoldersBeforeFiles(); + boolean favoritesFirst = preferences.isSortFavoritesFirst(); + mFiles = sortOrder.sortCloudFiles(mFiles, foldersBeforeFiles, favoritesFirst); + notifyDataSetChanged(); + + this.sortOrder = sortOrder; + } + + public Set getCheckedItems() { + return ocFileListDelegate.getCheckedItems(); + } + + public void setCheckedItem(Set files) { + ocFileListDelegate.setCheckedItem(files); + } + + public void clearCheckedItems() { + ocFileListDelegate.clearCheckedItems(); + } + + public void setFiles(List files) { + mFiles = files; + } + + public List getFiles() { + return mFiles; + } + + @Nullable + public OCFile getFileByRemoteId(@Nullable String fileId) { + return mFilesAll.stream() + .filter(f -> java.util.Objects.equals(fileId, f.getRemoteId())) + .findFirst() + .orElse(null); + } + + public void insertFile(@Nullable OCFile file) { + if (file == null) return; + + if (mFilesAll.contains(file)) return; + + mFilesAll.add(file); + mFiles.add(file); + + int position = getItemPosition(file); + if (position != -1) { + notifyItemInserted(position); + } + } + + public void addVirtualFile(@NonNull OCFile file) { + if (mFiles.isEmpty() || !mFiles.contains(file)) { + mFiles.add(file); + } + } + + @Override + public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) { + super.onViewRecycled(holder); + if (holder instanceof ListViewHolder listViewHolder) { + LoaderImageView thumbnailShimmer = listViewHolder.getShimmerThumbnail(); + DisplayUtils.stopShimmer(thumbnailShimmer, listViewHolder.getThumbnail()); + } + } + + @Override + public void avatarGenerated(Drawable avatarDrawable, Object callContext) { + ((ImageView) callContext).setImageDrawable(avatarDrawable); + } + + @Override + public boolean shouldCallGeneratedCallback(String tag, Object callContext) { + return ((ImageView) callContext).getTag().equals(tag); + } + + public boolean isCheckedFile(OCFile file) { + return ocFileListDelegate.isCheckedFile(file); + } + + public void addCheckedFile(OCFile file) { + ocFileListDelegate.addCheckedFile(file); + } + + public void setHighlightedItem(OCFile file) { + ocFileListDelegate.setHighlightedItem(file); + } + + public void cancelAllPendingTasks() { + ocFileListDelegate.cancelAllPendingTasks(); + } + + public void setGridView(boolean bool) { + gridView = bool; + } + + public void setShowMetadata(boolean bool) { + ocFileListDelegate.setMultiSelect(bool); + } + + @NonNull + @Override + public String getPopupText(View view, int position) { + OCFile file = getItem(position); + + if (file == null || sortOrder == null) { + return ""; + } + + switch (sortOrder.getType()) { + case ALPHABET: + return String.valueOf(file.getFileName().charAt(0)).toUpperCase(Locale.getDefault()); + case DATE: + long milliseconds = file.getModificationTimestamp(); + Date date = new Date(milliseconds); + return dateFormat.format(date); + case SIZE: + return DisplayUtils.bytesToHumanReadable(file.getFileLength()); + default: + Log_OC.d(TAG, "getPopupText: Unsupported sort order: " + sortOrder.getType()); + return ""; + } + } + + @VisibleForTesting + public void setShowShareAvatar(boolean bool) { + ocFileListDelegate.setShowShareAvatar(bool); + } + + @Override + public int getFilesCount() { + return mFiles.size(); + } + + @Override + public void notifyItemChanged(@NonNull OCFile file) { + if (shouldShowRecommendedFiles() && recommendedFilesAdapter != null && file.isRecommendedFile()) { + final int position = recommendedFilesAdapter.getItemPosition(file); + recommendedFilesAdapter.notifyItemChanged(position); + } else { + notifyItemChanged(getItemPosition(file)); + } + } + + @VisibleForTesting + public void setCurrentDirectory(OCFile folder) { + currentDirectory = folder; + } + + // payload only for local file indicator + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List payloads) { + if (!payloads.isEmpty() && payloads.get(0) instanceof Integer iconId && holder instanceof ListViewHolder listViewHolder) { + listViewHolder.getLocalFileIndicator().setImageResource(iconId); + listViewHolder.getLocalFileIndicator().setVisibility(View.VISIBLE); + // skip full rebind + return; + } + super.onBindViewHolder(holder, position, payloads); + } + + public void updateFileIndicator(int iconId, OCFile file) { + if (file == null) return; + + int position = getItemPosition(file); + if (position != -1) { + notifyItemChanged(position, iconId); + } + } + + public void cleanup() { + ocFileListDelegate.cleanup(); + helper.cleanup(); + } + + @SuppressLint("NotifyDataSetChanged") + public void removeAllFiles() { + mFiles.clear(); + mFilesAll.clear(); + notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt new file mode 100644 index 000000000000..65bc90380aaf --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt @@ -0,0 +1,457 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.content.Context +import android.view.View +import android.widget.ImageView +import androidx.core.content.ContextCompat +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.User +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.jobs.gallery.GalleryImageGenerationJob +import com.nextcloud.client.jobs.gallery.GalleryImageGenerationListener +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.OCFileUtils +import com.nextcloud.utils.extensions.makeRounded +import com.nextcloud.utils.extensions.setVisibleIf +import com.nextcloud.utils.mdm.MDMConfig +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.ComponentsGetter +import com.owncloud.android.ui.activity.FolderPickerActivity +import com.owncloud.android.ui.fragment.GalleryFragment +import com.owncloud.android.ui.fragment.SearchType +import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.overlay.OverlayManager +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import kotlinx.coroutines.launch + +@Suppress("LongParameterList", "TooManyFunctions") +class OCFileListDelegate( + private val fileUploadHelper: FileUploadHelper, + private val context: Context, + private val ocFileListFragmentInterface: OCFileListFragmentInterface, + private val user: User, + private val storageManager: FileDataStorageManager, + private val hideItemOptions: Boolean, + private val preferences: AppPreferences, + private val gridView: Boolean, + private val transferServiceGetter: ComponentsGetter, + private val showMetadata: Boolean, + private var showShareAvatar: Boolean, + private var viewThemeUtils: ViewThemeUtils, + private val syncFolderProvider: SyncedFolderProvider? = null +) { + private val tag = "OCFileListDelegate" + private val checkedFiles: MutableSet = HashSet() + private var highlightedItem: OCFile? = null + var isMultiSelect = false + private val asyncTasks: MutableList = ArrayList() + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val galleryImageGenerationJob = GalleryImageGenerationJob(user, storageManager) + + fun setHighlightedItem(highlightedItem: OCFile?) { + this.highlightedItem = highlightedItem + } + + fun isCheckedFile(file: OCFile): Boolean = checkedFiles.contains(file) + + fun addCheckedFile(file: OCFile) { + checkedFiles.add(file) + highlightedItem = null + } + + fun removeCheckedFile(file: OCFile) { + checkedFiles.remove(file) + } + + fun addToCheckedFiles(files: List?) { + checkedFiles.addAll(files!!) + } + + val checkedItems: Set + get() = checkedFiles + + fun setCheckedItem(files: Set?) { + checkedFiles.clear() + checkedFiles.addAll(files!!) + } + + fun clearCheckedItems() { + checkedFiles.clear() + } + + fun bindGalleryRow( + shimmer: LoaderImageView?, + imageView: ImageView, + file: OCFile, + galleryRowHolder: GalleryRowHolder, + imageDimension: Pair + ) { + // Cancel previous job for this ImageView + GalleryImageGenerationJob.cancelPreviousJob(imageView) + + imageView.tag = file.fileId + + // set placeholder before async job + val cacheKey = ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE + file.remoteId + val cachedBitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(cacheKey) + if (cachedBitmap != null) { + val overlay = if (MimeTypeUtil.isVideo(file)) { + ThumbnailsCacheManager.addVideoOverlay(cachedBitmap, context) + } else { + cachedBitmap + } + imageView.setImageBitmap(overlay) + } else { + imageView.setImageDrawable(OCFileUtils.getMediaPlaceholder(file, imageDimension)) + } + + val job = ioScope.launch { + try { + galleryImageGenerationJob.run( + file, + imageView, + object : GalleryImageGenerationListener { + override fun onSuccess() { + if (imageView.tag == file.fileId) { + Log_OC.d(tag, "setGalleryImage.onSuccess()") + galleryRowHolder.binding.rowLayout.invalidate() + DisplayUtils.stopShimmer(shimmer, imageView) + } + } + + override fun onNewGalleryImage() { + if (imageView.tag == file.fileId) { + Log_OC.d(tag, "setGalleryImage.updateRowVisuals()") + galleryRowHolder.updateRowVisuals() + } + } + + override fun onError() { + if (imageView.tag == file.fileId) { + Log_OC.d(tag, "setGalleryImage.onError()") + DisplayUtils.stopShimmer(shimmer, imageView) + } + } + } + ) + } finally { + val currentJob = coroutineContext.job + GalleryImageGenerationJob.removeActiveJob(imageView, currentJob) + } + } + + GalleryImageGenerationJob.storeJob(job, imageView) + + imageView.setOnClickListener { + ocFileListFragmentInterface.onItemClicked(file) + GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition) + } + + if (!hideItemOptions) { + imageView.apply { + isLongClickable = true + setOnLongClickListener { + ocFileListFragmentInterface.onLongItemClicked( + file + ) + } + } + } + } + + fun setThumbnail( + thumbnail: ImageView, + shimmerThumbnail: LoaderImageView?, + file: OCFile, + overlayManager: OverlayManager + ) { + DisplayUtils.setThumbnail( + file, + thumbnail, + user, + storageManager, + asyncTasks, + gridView, + context, + shimmerThumbnail, + preferences, + viewThemeUtils, + overlayManager + ) + } + + @Suppress("MagicNumber") + fun bindViewHolder( + viewHolder: ListViewHolder, + file: OCFile, + currentDirectory: OCFile?, + searchType: SearchType?, + overlayManager: OverlayManager + ) { + // thumbnail + viewHolder.imageFileName?.text = file.fileName + viewHolder.thumbnail.tag = file.fileId + setThumbnail(viewHolder.thumbnail, viewHolder.shimmerThumbnail, file, overlayManager) + + // item layout + click listeners + bindGridItemLayout(file, viewHolder) + + // unread comments + bindUnreadComments(file, viewHolder) + + // multiSelect (Checkbox) + val isFolderPickerActivity = (context is FolderPickerActivity) + viewHolder.checkbox.setVisibleIf(isMultiSelect && !isFolderPickerActivity) + + // download state + viewHolder.localFileIndicator.visibility = View.GONE // default first + + // metadata (downloaded, favorite) + bindGridMetadataViews(file, viewHolder) + + // shares + val shouldHideShare = ( + ( + hideItemOptions || + ( + !file.isFolder && + file.isEncrypted + ) || + ( + file.isEncrypted && + !EncryptionUtils.supportsSecureFiledrop(file, user) + ) || + (searchType == SearchType.FAVORITE_SEARCH) || + ( + file.isFolder && + (currentDirectory?.isEncrypted ?: false) + ) + ) + ) // sharing an encrypted subfolder is not possible + if (shouldHideShare) { + viewHolder.shared.visibility = View.GONE + } else { + configureSharedIconView(viewHolder, file) + } + + if (!file.isOfflineOperation && !file.isFolder) { + viewHolder.thumbnail.makeRounded(context, 4f) + } + } + + private fun bindUnreadComments(file: OCFile, gridViewHolder: ListViewHolder) { + if (file.unreadCommentsCount > 0) { + gridViewHolder.unreadComments.visibility = View.VISIBLE + gridViewHolder.unreadComments.setOnClickListener { + ocFileListFragmentInterface + .showActivityDetailView(file) + } + } else { + gridViewHolder.unreadComments.visibility = View.GONE + } + } + + private fun bindGridItemLayout(file: OCFile, gridViewHolder: ListViewHolder) { + setItemLayoutBackgroundColor(file, gridViewHolder) + setCheckBoxImage(file, gridViewHolder) + setItemLayoutOnClickListeners(file, gridViewHolder) + + gridViewHolder.more?.setOnClickListener { + ocFileListFragmentInterface.onOverflowIconClicked(file, it) + } + } + + private fun setItemLayoutOnClickListeners(file: OCFile, gridViewHolder: ListViewHolder) { + gridViewHolder.itemLayout.setOnClickListener { ocFileListFragmentInterface.onItemClicked(file) } + + if (!hideItemOptions && gridViewHolder !is OCFileListRecommendedItemViewHolder) { + gridViewHolder.itemLayout.apply { + isLongClickable = true + setOnLongClickListener { + ocFileListFragmentInterface.onLongItemClicked( + file + ) + } + } + } + } + + private fun setItemLayoutBackgroundColor(file: OCFile, gridViewHolder: ListViewHolder) { + val cornerRadius = context.resources.getDimension(R.dimen.selected_grid_container_radius) + + val isDarkModeActive = (syncFolderProvider?.preferences?.isDarkModeEnabled == true) + val selectedItemBackgroundColorId: Int = if (isDarkModeActive) { + R.color.action_mode_background + } else { + R.color.selected_item_background + } + + val itemLayoutBackgroundColorId: Int = if (file.fileId == highlightedItem?.fileId || isCheckedFile(file)) { + selectedItemBackgroundColorId + } else { + R.color.bg_default + } + + gridViewHolder.itemLayout.run { + makeRounded(context, cornerRadius) + setBackgroundColor(ContextCompat.getColor(context, itemLayoutBackgroundColorId)) + } + } + + private fun setCheckBoxImage(file: OCFile, gridViewHolder: ListViewHolder) { + if (isCheckedFile(file)) { + gridViewHolder.checkbox.setImageDrawable( + viewThemeUtils.platform.tintDrawable(context, R.drawable.ic_checkbox_marked, ColorRole.PRIMARY) + ) + } else { + gridViewHolder.checkbox.setImageResource(R.drawable.ic_checkbox_blank_outline) + } + } + + private fun bindGridMetadataViews(file: OCFile, gridViewHolder: ListViewHolder) { + if (showMetadata) { + showLocalFileIndicator(file, gridViewHolder) + gridViewHolder.favorite.visibility = if (file.isFavorite) View.VISIBLE else View.GONE + } else { + gridViewHolder.localFileIndicator.visibility = View.GONE + gridViewHolder.favorite.visibility = View.GONE + } + } + + private fun isSynchronizing(file: OCFile): Boolean { + val operationsServiceBinder = transferServiceGetter.operationsServiceBinder + val fileDownloadHelper = FileDownloadHelper.instance() + + return operationsServiceBinder?.isSynchronizing(user, file) == true || + fileDownloadHelper.isDownloading(user, file) || + fileUploadHelper.isUploading(file.remotePath, user.accountName) + } + + private fun OCFile.canCheckFolderDown(): Boolean = mimeType != null && + isFolder && + !isEncrypted && + fileLength != 0L && + !etag.isNullOrBlank() + + @Suppress("ComplexCondition") + private fun showLocalFileIndicator(file: OCFile, holder: ListViewHolder) { + var isFolderDown = false + if (file.canCheckFolderDown()) { + isFolderDown = storageManager.fileDao.areAllFilesHaveMediaPath(file.fileId, user.accountName) + } + + val isSyncing = isSynchronizing(file) + val hasConflict = (file.etagInConflict != null) + val isDown = file.isDown + + val icon = when { + isSyncing -> R.drawable.ic_synchronizing + hasConflict -> R.drawable.ic_synchronizing_error + isDown || isFolderDown -> R.drawable.ic_synced + else -> null + } + + holder.localFileIndicator.run { + if (icon != null && showMetadata) { + setImageResource(icon) + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + } + + private fun configureSharedIconView(gridViewHolder: ListViewHolder, file: OCFile) { + val result = getShareIconIdAndContentDescriptionId(gridViewHolder, file) + + gridViewHolder.shared.run { + if (result == null) { + visibility = View.GONE + return + } + + setImageResource(result.first) + contentDescription = context.getString(result.second) + visibility = View.VISIBLE + setOnClickListener { ocFileListFragmentInterface.onShareIconClick(file) } + } + } + + @Suppress("ReturnCount") + private fun getShareIconIdAndContentDescriptionId(holder: ListViewHolder, file: OCFile): Pair? { + if (!MDMConfig.sharingSupport(context)) { + return null + } + + if (file.isOfflineOperation) return null + + if (holder !is OCFileListItemViewHolder && file.unreadCommentsCount != 0) return null + + return when { + file.isSharedWithSharee || file.isSharedWithMe -> { + if (showShareAvatar) null else R.drawable.shared_via_users to R.string.shared_icon_shared + } + + file.isSharedViaLink -> R.drawable.shared_via_link to R.string.shared_icon_shared_via_link + + else -> R.drawable.ic_unshared to R.string.shared_icon_share + } + } + + fun cancelAllPendingTasks() { + for (task in asyncTasks) { + task.cancel(true) + if (task.getMethod != null) { + Log_OC.d(TAG, "cancel: abort get method directly") + task.getMethod.abort() + } + } + asyncTasks.clear() + } + + fun setShowShareAvatar(bool: Boolean) { + showShareAvatar = bool + } + + @Suppress("TooGenericExceptionCaught") + fun cleanup() { + ioScope.cancel() + + try { + GalleryImageGenerationJob.cancelAllActiveJobs() + } catch (e: Exception) { + Log_OC.e(TAG, "exception: ", e) + } + + // cancel async tasks from ThumbnailsCacheManager + cancelAllPendingTasks() + + Log_OC.d(TAG, "background jobs cancelled") + } + + companion object { + private val TAG = OCFileListDelegate::class.java.simpleName + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListFooterViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListFooterViewHolder.kt new file mode 100644 index 000000000000..33878df114ad --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListFooterViewHolder.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.databinding.ListFooterBinding + +internal class OCFileListFooterViewHolder(var binding: ListFooterBinding) : + RecyclerView.ViewHolder( + binding.root + ) { + val footerText + get() = binding.footerText + + val loadingProgressBar + get() = binding.loadingProgressBar +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt new file mode 100644 index 000000000000..484bf8f62622 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.owncloud.android.databinding.GridItemBinding + +class OCFileListGridItemViewHolder(var binding: GridItemBinding) : + RecyclerView.ViewHolder( + binding.root + ), + ListGridItemViewHolder { + val bidiFilename: TextView + get() = binding.bidiFilename + override val fileName: TextView + get() = binding.Filename + override val extension: TextView? + get() = if (binding.bidiFilenameContainer.isVisible) { + binding.bidiExtension + } else { + null + } + override val thumbnail: ImageView + get() = binding.thumbnail + + override fun showVideoOverlay() { + binding.videoOverlay.visibility = View.VISIBLE + } + + override val shimmerThumbnail: LoaderImageView + get() = binding.thumbnailShimmer + override val favorite: ImageView + get() = binding.favoriteAction + override val localFileIndicator: ImageView + get() = binding.localFileIndicator + override val imageFileName: TextView? + get() = null + override val shared: ImageView + get() = binding.sharedIcon + override val checkbox: ImageView + get() = binding.customCheckbox + override val itemLayout: View + get() = binding.ListItemLayout + override val unreadComments: ImageView + get() = binding.unreadComments + + override val gridLivePhotoIndicator: ImageView? + get() = null + override val livePhotoIndicator: TextView? + get() = null + override val livePhotoIndicatorSeparator: TextView? + get() = null + override val hasVisibleFeatureIndicators: Boolean + get() = localFileIndicator.isVisible || gridLivePhotoIndicator?.isVisible == true || unreadComments.isVisible || + shared.isVisible || binding.videoOverlay.isVisible || favorite.isVisible + override val fileFeaturesLayout: LinearLayout + get() = binding.fileFeaturesLayout + override val more: ImageButton + get() = if (binding.bidiFilenameContainer.isVisible) { + binding.bidiMore + } else { + binding.more + } + init { + binding.favoriteAction.drawable.mutate() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListHeaderViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListHeaderViewHolder.kt new file mode 100644 index 000000000000..c6c47fb5c589 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListHeaderViewHolder.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.databinding.ListHeaderBinding + +internal class OCFileListHeaderViewHolder(var binding: ListHeaderBinding) : + RecyclerView.ViewHolder( + binding.root + ) { + val headerText + get() = binding.headerText + + val headerView + get() = binding.headerView +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt new file mode 100644 index 000000000000..3336b2b62663 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.owncloud.android.databinding.ListItemBinding +import com.owncloud.android.ui.AvatarGroupLayout + +class OCFileListItemViewHolder(private var binding: ListItemBinding) : + RecyclerView.ViewHolder( + binding.root + ), + ListItemViewHolder { + override val gridLivePhotoIndicator: ImageView? + get() = null + override val livePhotoIndicator: TextView + get() = binding.livePhotoIndicator + override val livePhotoIndicatorSeparator: TextView + get() = binding.livePhotoIndicatorSeparator + override val hasVisibleFeatureIndicators: Boolean + get() = false + + override val fileSize: TextView + get() = binding.fileSize + override val fileSizeSeparator: View + get() = binding.fileSeparator + override val lastModification: TextView + get() = binding.lastMod + override val overflowMenu: ImageView + get() = binding.overflowMenu + override val sharedAvatars: AvatarGroupLayout + get() = binding.sharedAvatars + override val fileName: TextView + get() = binding.Filename + override val extension: TextView + get() = binding.extension + override val thumbnail: ImageView + get() = binding.thumbnailLayout.thumbnail + override val tagsGroup: ChipGroup + get() = binding.tagsGroup + override val firstTag: Chip + get() = binding.firstTag + override val secondTag: Chip + get() = binding.secondTag + override val tagMore: Chip + get() = binding.tagMore + override val fileDetailGroup: LinearLayout + get() = binding.fileDetailGroup + + override fun showVideoOverlay() { + binding.thumbnailLayout.videoOverlay.visibility = View.VISIBLE + } + + override val more: ImageButton? + get() = null + override val fileFeaturesLayout: LinearLayout? + get() = null + override val shimmerThumbnail: LoaderImageView + get() = binding.thumbnailLayout.thumbnailShimmer + override val favorite: ImageView + get() = binding.favoriteAction + override val localFileIndicator: ImageView + get() = binding.localFileIndicator + override val imageFileName: TextView? + get() = null + override val shared: ImageView + get() = binding.sharedIcon + override val checkbox: ImageView + get() = binding.customCheckbox + override val itemLayout: View + get() = binding.ListItemLayout + override val unreadComments: ImageView + get() = binding.unreadComments + + init { + binding.favoriteAction.drawable.mutate() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListRecommendedItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListRecommendedItemViewHolder.kt new file mode 100644 index 000000000000..9d805087a627 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListRecommendedItemViewHolder.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.owncloud.android.databinding.RecommendedFileItemBinding + +class OCFileListRecommendedItemViewHolder(private val binding: RecommendedFileItemBinding) : + RecyclerView.ViewHolder(binding.root), + ListGridItemViewHolder { + + val reason: TextView get() = binding.reason + override val fileName: TextView get() = binding.filename + override val extension: TextView? get() = binding.extension + override val thumbnail: ImageView get() = binding.thumbnail + override val shimmerThumbnail: LoaderImageView get() = binding.thumbnailShimmer + override val favorite: ImageView get() = binding.favoriteAction + override val localFileIndicator: ImageView get() = binding.localFileIndicator + override val shared: ImageView get() = binding.sharedIcon + override val checkbox: ImageView get() = binding.customCheckbox + override val itemLayout: View get() = binding.recommendedFileItemLayout + override val unreadComments: ImageView get() = binding.unreadComments + override val fileFeaturesLayout: LinearLayout get() = binding.fileFeaturesLayout + override val more: ImageButton get() = binding.more + + override val imageFileName: TextView? get() = null + override val gridLivePhotoIndicator: ImageView? get() = null + override val livePhotoIndicator: TextView? get() = null + override val livePhotoIndicatorSeparator: TextView? get() = null + override val hasVisibleFeatureIndicators: Boolean + get() = localFileIndicator.isVisible || gridLivePhotoIndicator?.isVisible == true || unreadComments.isVisible || + shared.isVisible || binding.videoOverlay.isVisible || favorite.isVisible + + override fun showVideoOverlay() { + binding.videoOverlay.visibility = View.VISIBLE + } + + init { + binding.favoriteAction.drawable.mutate() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListViewHolder.kt new file mode 100644 index 000000000000..1de965ae1083 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListViewHolder.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2022 Tobias Kaminsky + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.owncloud.android.databinding.GridItemBinding + +internal class OCFileListViewHolder(var binding: GridItemBinding) : + RecyclerView.ViewHolder( + binding.root + ), + ListViewHolder { + + override val thumbnail: ImageView + get() = binding.thumbnail + + override val imageFileName: TextView + get() = if (binding.bidiFilenameContainer.isVisible) { + binding.bidiFilename + } else { + binding.Filename + } + + override fun showVideoOverlay() { + // noop + } + + override val shimmerThumbnail: LoaderImageView + get() = binding.thumbnailShimmer + override val favorite: ImageView + get() = binding.favoriteAction + override val localFileIndicator: ImageView + get() = binding.localFileIndicator + override val shared: ImageView + get() = binding.sharedIcon + override val checkbox: ImageView + get() = binding.customCheckbox + override val itemLayout: View + get() = binding.ListItemLayout + override val unreadComments: ImageView + get() = binding.unreadComments + override val more: ImageButton + get() = if (binding.bidiFilenameContainer.isVisible) { + binding.bidiMore + } else { + binding.more + } + override val fileFeaturesLayout: LinearLayout + get() = binding.fileFeaturesLayout + override val gridLivePhotoIndicator: ImageView + get() = binding.gridLivePhotoIndicator + override val livePhotoIndicator: TextView? + get() = null + override val livePhotoIndicatorSeparator: TextView? + get() = null + override val hasVisibleFeatureIndicators: Boolean + get() = localFileIndicator.isVisible || gridLivePhotoIndicator.isVisible || unreadComments.isVisible || + shared.isVisible || binding.videoOverlay.isVisible || favorite.isVisible + + init { + binding.favoriteAction.drawable.mutate() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt new file mode 100644 index 000000000000..cd80aa84a0b7 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt @@ -0,0 +1,128 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.adapter + +import com.nextcloud.utils.extensions.saveShares +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.lib.resources.shares.ShareeUser +import com.owncloud.android.utils.FileStorageUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +object OCShareToOCFileConverter { + private const val MILLIS_PER_SECOND = 1000 + private val LINK_SHARE_TYPES = setOf(ShareType.PUBLIC_LINK, ShareType.EMAIL) + + /** + * Generates a list of incomplete [OCFile] from a list of [OCShare]. Retrieving OCFile directly by path may fail + * in cases like + * when a shared file is located at a/b/c/d/a.txt. To display a.txt in the shared tab, the device needs the OCFile. + * On first launch, the app may not be aware of the file until the exact path is accessed. + * + * Server implementation needed to get file size, thumbnails e.g. : + * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.print.PageRange; +import android.print.PrintAttributes; +import android.print.PrintDocumentAdapter; +import android.print.PrintDocumentInfo; + +import com.owncloud.android.lib.common.utils.Log_OC; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class PrintAdapter extends PrintDocumentAdapter { + private static final String TAG = PrintAdapter.class.getSimpleName(); + private static final String PDF_NAME = "finalPrint.pdf"; + + private final String filePath; + + public PrintAdapter(String filePath) { + this.filePath = filePath; + } + + @Override + public void onLayout(PrintAttributes oldAttributes, + PrintAttributes newAttributes, + CancellationSignal cancellationSignal, + LayoutResultCallback callback, + Bundle extras) { + if (cancellationSignal.isCanceled()) { + callback.onLayoutCancelled(); + } else { + PrintDocumentInfo.Builder builder = new PrintDocumentInfo.Builder(PDF_NAME); + builder.setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) + .setPageCount(PrintDocumentInfo.PAGE_COUNT_UNKNOWN) + .build(); + callback.onLayoutFinished(builder.build(), !newAttributes.equals(oldAttributes)); + } + } + + + @Override + public void onWrite(PageRange[] pages, + ParcelFileDescriptor destination, + CancellationSignal cancellationSignal, + WriteResultCallback callback) { + + try (InputStream in = new FileInputStream(filePath); + OutputStream out = new FileOutputStream(destination.getFileDescriptor())) { + + byte[] buf = new byte[16384]; + int size; + + while ((size = in.read(buf)) >= 0 && !cancellationSignal.isCanceled()) { + out.write(buf, 0, size); + } + + if (cancellationSignal.isCanceled()) { + callback.onWriteCancelled(); + } else { + callback.onWriteFinished(new PageRange[]{PageRange.ALL_PAGES}); + } + + } catch (IOException e) { + Log_OC.e(TAG, "Error using temp file", e); + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/QuickSharingPermissionsAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/QuickSharingPermissionsAdapter.kt new file mode 100644 index 000000000000..4ac0e862160b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/QuickSharingPermissionsAdapter.kt @@ -0,0 +1,84 @@ +/* + * Nextcloud Android client application + * + * @author TSI-mc + * Copyright (C) 2021 TSI-mc + * Copyright (C) 2021 Nextcloud GmbH + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android.ui.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.owncloud.android.R +import com.owncloud.android.databinding.ItemQuickSharePermissionsBinding +import com.owncloud.android.datamodel.quickPermission.QuickPermission +import com.owncloud.android.utils.theme.ViewThemeUtils + +class QuickSharingPermissionsAdapter( + private val quickPermissionList: MutableList, + private val onPermissionChangeListener: QuickSharingPermissionViewHolder.OnPermissionChangeListener, + private val viewThemeUtils: ViewThemeUtils +) : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding = ItemQuickSharePermissionsBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return QuickSharingPermissionViewHolder(binding, binding.root, onPermissionChangeListener, viewThemeUtils) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is QuickSharingPermissionViewHolder) { + holder.bindData(quickPermissionList[position]) + } + } + + override fun getItemCount(): Int = quickPermissionList.size + + class QuickSharingPermissionViewHolder( + private val binding: ItemQuickSharePermissionsBinding, + itemView: View, + private val onPermissionChangeListener: OnPermissionChangeListener, + private val viewThemeUtils: ViewThemeUtils + ) : RecyclerView.ViewHolder(itemView) { + + fun bindData(quickPermission: QuickPermission) { + val context = itemView.context + val permissionName = quickPermission.type.getText(context) + + binding.run { + quickPermissionButton.text = permissionName + quickPermissionButton.iconGravity = MaterialButton.ICON_GRAVITY_START + quickPermissionButton.icon = quickPermission.type.getIcon(context) + + if (quickPermission.isSelected) { + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(quickPermissionButton) + } + } + + val customPermissionName = context.getString(R.string.share_custom_permission) + val isCustomPermission = permissionName.equals(customPermissionName, ignoreCase = true) + + itemView.setOnClickListener { + if (isCustomPermission) { + onPermissionChangeListener.onCustomPermissionSelected() + } else if (!quickPermission.isSelected) { + // if user select different options then only update the permission + onPermissionChangeListener.onPermissionChanged(absoluteAdapterPosition) + } else { + // dismiss sheet on selection of same permission + onPermissionChangeListener.onDismissSheet() + } + } + } + + interface OnPermissionChangeListener { + fun onPermissionChanged(position: Int) + fun onCustomPermissionSelected() + fun onDismissSheet() + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt new file mode 100644 index 000000000000..cbac02172939 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt @@ -0,0 +1,166 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.client.account.User +import com.owncloud.android.databinding.UploaderListItemLayoutBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderObserver +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.datamodel.ThumbnailsCacheManager.AsyncThumbnailDrawable +import com.owncloud.android.datamodel.ThumbnailsCacheManager.ThumbnailGenerationTask +import com.owncloud.android.datamodel.ThumbnailsCacheManager.ThumbnailGenerationTaskObject +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import java.util.Objects + +@Suppress("LongParameterList") +class ReceiveExternalFilesAdapter( + private val files: List, + private val context: Context, + private val user: User, + private val storageManager: FileDataStorageManager, + private val viewThemeUtils: ViewThemeUtils, + private val syncedFolderProvider: SyncedFolderProvider, + private val onItemClickListener: OnItemClickListener +) : RecyclerView.Adapter() { + + private var filteredFiles: List = files + + interface OnItemClickListener { + fun selectFile(file: OCFile) + } + + inner class ReceiveExternalViewHolder(val binding: UploaderListItemLayoutBinding) : + RecyclerView.ViewHolder(binding.root) { + init { + binding.root.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + onItemClickListener.selectFile(filteredFiles[position]) + } + } + } + } + + @SuppressLint("NotifyDataSetChanged") + fun filter(query: String) { + filteredFiles = if (query.isEmpty()) { + files + } else { + files.filter { file -> + file.fileName.contains(query, ignoreCase = true) + } + } + notifyDataSetChanged() + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ReceiveExternalViewHolder { + val binding = UploaderListItemLayoutBinding + .inflate(LayoutInflater.from(viewGroup.context), viewGroup, false) + return ReceiveExternalViewHolder(binding) + } + + override fun onBindViewHolder(viewHolder: ReceiveExternalViewHolder, position: Int) { + val file = filteredFiles[position] + + viewHolder.binding.filename.text = file.fileName + viewHolder.binding.lastMod.text = DisplayUtils.getRelativeTimestamp(context, file.modificationTimestamp) + + if (!file.isFolder) { + viewHolder.binding.fileSize.text = DisplayUtils.bytesToHumanReadable(file.fileLength) + } + + viewHolder.binding.fileSize.visibility = if (file.isFolder) { + View.GONE + } else { + View.VISIBLE + } + viewHolder.binding.fileSeparator.visibility = if (file.isFolder) { + View.GONE + } else { + View.VISIBLE + } + + val thumbnailImageView = viewHolder.binding.thumbnail + setupThumbnail(thumbnailImageView, file) + } + + private fun setupThumbnail(thumbnailImageView: ImageView, file: OCFile) { + thumbnailImageView.tag = file.fileId + + if (file.isFolder) { + setupThumbnailForFolder(thumbnailImageView, file) + } else if (MimeTypeUtil.isImage(file) && file.remoteId != null) { + setupThumbnailForImage(thumbnailImageView, file) + } else { + setupDefaultThumbnail(thumbnailImageView, file) + } + } + + private fun setupThumbnailForFolder(thumbnailImageView: ImageView, file: OCFile) { + val isAutoUploadFolder = SyncedFolderObserver.isAutoUploadFolder(file, user) + val isDarkModeActive = syncedFolderProvider.preferences.isDarkModeEnabled + val overlayIconId = file.getFileOverlayIconId(isAutoUploadFolder) + val icon = MimeTypeUtil.getFolderIcon(isDarkModeActive, overlayIconId, context, viewThemeUtils) + thumbnailImageView.setImageDrawable(icon) + } + + @Suppress("NestedBlockDepth") + private fun setupThumbnailForImage(thumbnailImageView: ImageView, file: OCFile) { + var thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId.toString()) + if (thumbnail != null && !file.isUpdateThumbnailNeeded) { + thumbnailImageView.setImageBitmap(thumbnail) + } else { + // generate new Thumbnail + if (ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailImageView)) { + val task = ThumbnailGenerationTask(thumbnailImageView, storageManager, user) + if (thumbnail == null) { + thumbnail = if (MimeTypeUtil.isVideo(file)) { + ThumbnailsCacheManager.mDefaultVideo + } else { + ThumbnailsCacheManager.mDefaultImg + } + } + val asyncDrawable = AsyncThumbnailDrawable( + context.resources, + thumbnail, + task + ) + thumbnailImageView.setImageDrawable(asyncDrawable) + + @Suppress("DEPRECATION") + task.execute(ThumbnailGenerationTaskObject(file, file.remoteId)) + } + } + } + + private fun setupDefaultThumbnail(thumbnailImageView: ImageView, file: OCFile) { + val icon = MimeTypeUtil.getFileTypeIcon( + file.mimeType, + file.fileName, + context, + viewThemeUtils + ) + thumbnailImageView.setImageDrawable(icon) + } + + override fun getItemCount() = filteredFiles.size + + fun getFileNames(): Set = files.map { it.fileName }.toSet() +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/RecommendedFilesAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/RecommendedFilesAdapter.kt new file mode 100644 index 000000000000..f00c483b28eb --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/RecommendedFilesAdapter.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.databinding.RecommendedFileItemBinding +import com.owncloud.android.datamodel.OCFile + +class RecommendedFilesAdapter( + private val fileListAdapter: OCFileListAdapter, + private val recommendations: List +) : RecyclerView.Adapter() { + + fun getItemPosition(file: OCFile): Int = recommendations.indexOf(file) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OCFileListRecommendedItemViewHolder { + val binding = RecommendedFileItemBinding + .inflate(LayoutInflater.from(parent.context), parent, false) + return OCFileListRecommendedItemViewHolder(binding) + } + + override fun getItemCount(): Int = recommendations.size + + override fun onBindViewHolder(holder: OCFileListRecommendedItemViewHolder, position: Int) { + val item = recommendations[position] + fileListAdapter.bindRecommendedFilesHolder(holder, item) + holder.reason.text = item.reason + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/RichDocumentsTemplateAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/RichDocumentsTemplateAdapter.java new file mode 100644 index 000000000000..d4aa0fe99c6e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/RichDocumentsTemplateAdapter.java @@ -0,0 +1,144 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.nextcloud.client.account.CurrentAccountProvider; +import com.nextcloud.utils.GlideHelper; +import com.owncloud.android.R; +import com.owncloud.android.databinding.TemplateButtonBinding; +import com.owncloud.android.datamodel.Template; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.dialog.ChooseRichDocumentsTemplateDialogFragment; +import com.owncloud.android.utils.NextcloudServer; +import com.owncloud.android.utils.theme.ViewThemeUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Adapter for handling Templates, used to create files out of it via RichDocuments app + */ +public class RichDocumentsTemplateAdapter extends RecyclerView.Adapter { + + private List