diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..bc7cbef --- /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 0000000..e285b0c --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,20 @@ +FROM ubuntu:noble@sha256:c35e29c9450151419d9448b0fd75374fec4fff364a27f176fb458d472dfc9e54 + +ARG DEBIAN_FRONTEND=noninteractive +ENV ANDROID_HOME=/usr/lib/android-sdk + +RUN apt-get update -y +RUN apt-get install -y unzip wget openjdk-17-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" && \ + echo "kapt.incremental.apt=true" >> "$HOME/.gradle/gradle.properties" \ No newline at end of file diff --git a/.devcontainer/Dockerfile.license b/.devcontainer/Dockerfile.license new file mode 100644 index 0000000..d078384 --- /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 0000000..60bcf90 --- /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 0000000..369163c --- /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 0000000..d078384 --- /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 0000000..a13d6f9 --- /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 0000000..d078384 --- /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 new file mode 100644 index 0000000..1541f7f --- /dev/null +++ b/.drone.yml @@ -0,0 +1,190 @@ +--- +kind: pipeline +type: docker +name: tests-stable + +# SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + + +steps: + - name: gplay + image: ghcr.io/nextcloud/continuous-integration-android8:4 + 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 + +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 + - 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 + +trigger: + branch: + - master + - stable-* + event: + - push + - pull_request +--- +kind: pipeline +type: docker +name: tests-master + +steps: + - name: gplay + image: ghcr.io/nextcloud/continuous-integration-android8:4 + 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 + - scripts/runCombinedTest.sh $DRONE_PULL_REQUEST $LOG_USERNAME $LOG_PASSWORD $DRONE_BUILD_NUMBER + +services: + - name: server + image: ghcr.io/nextcloud/continuous-integration-shallow-server:latest # also change in updateScreenshots.sh + environment: + EVAL: true + commands: + - /usr/local/bin/initnc.sh + - echo 127.0.0.1 server >> /etc/hosts + - 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-android8:4 + 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 + from: nextcloud-drone@kaminsky.me + recipients_only: true + 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: b4568fe767026f67cca8c416c20cc522fd1d06941c836ca7eb4955682855d237 + +... diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..67e5fa3 --- /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/.gitignore b/.gitignore new file mode 100644 index 0000000..5924755 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# 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 + +# Java class files +*.class + +# generated files +bin/ +build/ +*.iml +gen/ +target/ + +# Local configuration files (sdk path, etc) +local.properties +tests/local.properties + +# Mac .DS_Store files +.DS_Store + +# Proguard README +proguard-project.txt +tests/proguard-project.txt + +# Android Studio and Gradle specific entries +.gradle +.idea/* +!.idea/codeStyles/ +build +/gradle.properties +.attach_pid* +fastlane/Fastfile +*.hprof + +# 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 0000000..23a6687 --- /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 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.pullapprove.yml b/.pullapprove.yml new file mode 100644 index 0000000..02b4872 --- /dev/null +++ b/.pullapprove.yml @@ -0,0 +1,50 @@ +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)' + labels: + - 1. developing + # custom message that will be used for the GitHub status + explanation: 'This PR is a work in progress...' + +# Group settings to apply to all groups by default, optionally being overridden later +group_defaults: + author_approval: + ignored: true + approve_by_comment: + enabled: true + approve_regex: '^(Approved|:shipit:|:\+1:|LGTM|Merge)' + reject_regex: '^(Rejected|:-1:)' + reset_on_push: + enabled: false + reset_on_reopened: + enabled: true + conditions: + labels: + exclude: + - dependencies + +groups: + code-review: + required: 1 + reject_value: -99 + users: + - AndyScherzinger + - tobiasKaminsky + - mario + - przybylski + - ardevd + + design-review: + conditions: + labels: + - design + reset_on_push: + enabled: false + required: 1 + reject_value: -99 + users: + - jancborchardt + - eppfel diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000..f0d4139 --- /dev/null +++ b/.tx/config @@ -0,0 +1,10 @@ +[main] +host = https://www.transifex.com + +[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 +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 0000000..39d0c45 --- /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/CHANGELOG.md b/CHANGELOG.md index 144f074..3374f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,95 @@ +## 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 diff --git a/README.md b/README.md index 2c9f897..b4d33da 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,6 @@ [![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) -[](https://play.google.com/store/apps/details?id=com.nextcloud.client) -[](https://f-droid.org/packages/com.nextcloud.client/) - Signing certificate fingerprint to [verify](https://developer.android.com/studio/command-line/apksigner#usage-verify) the APK: - 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) diff --git a/Readme-AR.md b/Readme-AR.md new file mode 100644 index 0000000..98735aa --- /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/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..7c5764b --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,522 @@ +/* + * 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.kapt) + 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 = 3 +val versionMinor = 35 +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/config.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 { + applicationId = "com.nextcloud.client" + minSdk = 27 + targetSdk = 36 + compileSdk = 36 + + buildConfigField("boolean", "CI", ciBuild.toString()) + buildConfigField("boolean", "RUNTIME_PERF_ANALYSIS", perfAnalysis.toString()) + + javaCompileOptions.annotationProcessorOptions { + arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") + } + + // arguments to be passed to functional tests + testInstrumentationRunner = if (shotTest) "com.karumi.shot.ShotTestRunner" + else "com.nextcloud.client.TestRunner" + + testInstrumentationRunnerArguments += mapOf( + "TEST_SERVER_URL" to ncTestServerBaseUrl.toString(), + "TEST_SERVER_USERNAME" to ncTestServerUsername.toString(), + "TEST_SERVER_PASSWORD" to ncTestServerPassword.toString() + ) + testInstrumentationRunnerArguments["disableAnalytics"] = "true" + + 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") + resConfigs("xxxhdpi") + + buildConfigField( + "String", + "NC_TEST_SERVER_DATA_STRING", + "\"nc://login/user:${ncTestServerUsername}&password:${ncTestServerPassword}&server:${ncTestServerBaseUrl}\"" + ) + } + } + + 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_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + 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")) + } + } + +} + +kapt.useBuildCache = true + +ksp.arg("room.schemaLocation", "$projectDir/schemas") + +kotlin.compilerOptions.jvmTarget.set(JvmTarget.JVM_17) + +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 = 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.ui.tooling.preview) + 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 + "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) + // 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) + kapt(libs.prism4j.bundler) + // 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 UI + implementation(libs.ui) + // endregion + + // region Image loading + implementation(libs.coil) + // endregion + + // kotlinx.serialization + implementation(libs.kotlinx.serialization.json) +} diff --git a/app/lint.xml b/app/lint.xml index fcbde65..2316b42 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -39,6 +39,10 @@ + + + + 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 0000000..203afe3 --- /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 0000000..694e08c --- /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 0000000..4dc4849 --- /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/src/androidTest/java/com/nextcloud/client/SyncedFoldersActivityIT.kt b/app/src/androidTest/java/com/nextcloud/client/SyncedFoldersActivityIT.kt index f095f1a..0709359 100644 --- a/app/src/androidTest/java/com/nextcloud/client/SyncedFoldersActivityIT.kt +++ b/app/src/androidTest/java/com/nextcloud/client/SyncedFoldersActivityIT.kt @@ -119,7 +119,8 @@ class SyncedFoldersActivityIT : AbstractIT() { onIdleSync { EspressoIdlingResource.increment() val dialog = sut.buildPowerCheckDialog() - dialog.show() + sut.showPowerCheckDialog() + EspressoIdlingResource.decrement() val screenShotName = createName(testClassName + "_" + "showPowerCheckDialog", "") diff --git a/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt b/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt index e7cb55f..5463af8 100644 --- a/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt +++ b/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt @@ -7,7 +7,7 @@ */ package com.nextcloud.client.assistant -import com.nextcloud.client.assistant.repository.AssistantRepository +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 @@ -18,11 +18,11 @@ import org.junit.Test @Suppress("MagicNumber") class AssistantRepositoryTests : AbstractOnServerIT() { - private var sut: AssistantRepository? = null + private var sut: AssistantRemoteRepositoryImpl? = null @Before fun setup() { - sut = AssistantRepository(nextcloudClient, capability) + sut = AssistantRemoteRepositoryImpl(nextcloudClient, capability) } @Test 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 index 5a9d1bf..231cf26 100644 --- a/app/src/androidTest/java/com/nextcloud/client/files/download/TransferManagerConnectionTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/files/download/TransferManagerConnectionTest.kt @@ -202,7 +202,7 @@ class TransferManagerConnectionTest { connection.onServiceConnected(componentName, binder) // WHEN - // is runnign flag accessed + // is running flag accessed val isRunning = connection.isRunning // THEN 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 0000000..e6b52ce --- /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 0000000..a2394f4 --- /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/GetExifOrientationTests.kt b/app/src/androidTest/java/com/nextcloud/extensions/GetExifOrientationTests.kt new file mode 100644 index 0000000..3475c8e --- /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/utils/AutoRenameTests.kt b/app/src/androidTest/java/com/nextcloud/utils/AutoRenameTests.kt index 3640959..68e99b9 100644 --- a/app/src/androidTest/java/com/nextcloud/utils/AutoRenameTests.kt +++ b/app/src/androidTest/java/com/nextcloud/utils/AutoRenameTests.kt @@ -10,6 +10,7 @@ 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 @@ -27,6 +28,7 @@ class AutoRenameTests : AbstractOnServerIT() { testOnlyOnServer(NextcloudVersion.nextcloud_30) capability = capability.apply { + isWCFEnabled = CapabilityBooleanType.TRUE forbiddenFilenameExtensionJson = listOf( """[" ",".",".part",".part"]""", """[".",".part",".part"," "]""", @@ -238,4 +240,14 @@ class AutoRenameTests : AbstractOnServerIT() { val expectedFilename = "Foo.Bar.Baz" assert(result == expectedFilename) { "Expected $expectedFilename but got $result" } } + + @Test + fun skipAutoRenameWhenWCFDisabled() { + capability = capability.apply { + isWCFEnabled = CapabilityBooleanType.FALSE + } + val filename = " readme.txt " + val result = AutoRename.rename(filename, capability, isFolderPath = true) + assert(result == filename) { "Expected $filename but got $result" } + } } 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 0000000..c16e705 --- /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/FileHelperTest.kt b/app/src/androidTest/java/com/nextcloud/utils/FileHelperTest.kt new file mode 100644 index 0000000..8f01315 --- /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 index 1b29fe0..89d4bb3 100644 --- a/app/src/androidTest/java/com/nextcloud/utils/FileNameValidatorTests.kt +++ b/app/src/androidTest/java/com/nextcloud/utils/FileNameValidatorTests.kt @@ -10,6 +10,7 @@ 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 @@ -27,6 +28,7 @@ class FileNameValidatorTests : AbstractOnServerIT() { @Before fun setup() { capability = capability.apply { + isWCFEnabled = CapabilityBooleanType.TRUE forbiddenFilenamesJson = """[".htaccess",".htaccess"]""" forbiddenFilenameBaseNamesJson = """ ["con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4", @@ -228,4 +230,14 @@ class FileNameValidatorTests : AbstractOnServerIT() { val result = FileNameValidator.checkFolderAndFilePaths(folderPath, listOf(), capability, targetContext) assertFalse(result) } + + @Test + fun skipValidationWhenWCFDisabled() { + capability = capability.apply { + isWCFEnabled = CapabilityBooleanType.FALSE + } + val filename = "abc.txt" + val result = FileNameValidator.checkFileName(filename, capability, targetContext) + assertNull(result) + } } diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java index f132bd0..5ea2754 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java @@ -400,11 +400,6 @@ public abstract class AbstractIT { public boolean isPowerSavingEnabled() { return false; } - - @Override - public boolean isPowerSavingExclusionAvailable() { - return false; - } }; UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext); diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java index 3a25e46..fd452db 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java @@ -216,11 +216,6 @@ public abstract class AbstractOnServerIT extends AbstractIT { public boolean isPowerSavingEnabled() { return false; } - - @Override - public boolean isPowerSavingExclusionAvailable() { - return false; - } }; UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext); diff --git a/app/src/androidTest/java/com/owncloud/android/UploadIT.java b/app/src/androidTest/java/com/owncloud/android/UploadIT.java index cd3980b..8072bb5 100644 --- a/app/src/androidTest/java/com/owncloud/android/UploadIT.java +++ b/app/src/androidTest/java/com/owncloud/android/UploadIT.java @@ -82,12 +82,6 @@ public class UploadIT extends AbstractOnServerIT { public boolean isPowerSavingEnabled() { return false; } - - @Override - public boolean isPowerSavingExclusionAvailable() { - return false; - } - @NonNull @Override public BatteryStatus getBattery() { @@ -237,11 +231,6 @@ public class UploadIT extends AbstractOnServerIT { return false; } - @Override - public boolean isPowerSavingExclusionAvailable() { - return false; - } - @NonNull @Override public BatteryStatus getBattery() { diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.java b/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.java index 3d7d52b..9c255ef 100644 --- a/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.java +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.java @@ -18,6 +18,7 @@ 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.UploadEntityKt; import com.nextcloud.test.RandomStringGenerator; import com.owncloud.android.AbstractIT; import com.owncloud.android.MainApp; @@ -108,7 +109,7 @@ public class UploadStorageManagerTest extends AbstractIT { OCUpload upload = createUpload(account); uploads.add(upload); - uploadsStorageManager.storeUpload(upload); + uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(upload)); } OCUpload[] storedUploads = uploadsStorageManager.getAllStoredUploads(); @@ -151,17 +152,14 @@ public class UploadStorageManagerTest extends AbstractIT { account.name); corruptUpload.setLocalPath(null); - - uploadsStorageManager.storeUpload(corruptUpload); - + uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(corruptUpload)); uploadsStorageManager.getAllStoredUploads(); } @Test public void getById() { OCUpload upload = createUpload(account); - long id = uploadsStorageManager.storeUpload(upload); - + long id = uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(upload)); OCUpload newUpload = uploadsStorageManager.getUploadById(id); assertNotNull(newUpload); @@ -178,7 +176,7 @@ public class UploadStorageManagerTest extends AbstractIT { private void insertUploads(Account account, int rowsToInsert) { for (int i = 0; i < rowsToInsert; i++) { - uploadsStorageManager.storeUpload(createUpload(account)); + uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(createUpload(account))); } } 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 index 3f8f73e..e9f8444 100644 --- a/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt @@ -46,9 +46,6 @@ abstract class FileUploaderIT : AbstractOnServerIT() { override val isPowerSavingEnabled: Boolean get() = false - override val isPowerSavingExclusionAvailable: Boolean - get() = false - override val battery: BatteryStatus get() = BatteryStatus() } @@ -327,7 +324,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() { user, null, ocUpload2, - NameCollisionPolicy.CANCEL, + NameCollisionPolicy.SKIP, FileUploadWorker.LOCAL_BEHAVIOUR_COPY, targetContext, false, @@ -376,7 +373,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() { user, arrayOf(ocFile2), FileUploadWorker.LOCAL_BEHAVIOUR_COPY, - NameCollisionPolicy.CANCEL + NameCollisionPolicy.SKIP ) shortSleep() @@ -403,7 +400,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() { user, null, ocUpload, - NameCollisionPolicy.CANCEL, + NameCollisionPolicy.SKIP, FileUploadWorker.LOCAL_BEHAVIOUR_COPY, targetContext, false, @@ -429,7 +426,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() { user, null, ocUpload2, - NameCollisionPolicy.CANCEL, + NameCollisionPolicy.SKIP, FileUploadWorker.LOCAL_BEHAVIOUR_COPY, targetContext, false, @@ -480,7 +477,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() { user, arrayOf(ocFile2), FileUploadWorker.LOCAL_BEHAVIOUR_COPY, - NameCollisionPolicy.CANCEL + NameCollisionPolicy.SKIP ) shortSleep() 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 index c9ec5e0..8f65ddc 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/activity/FileDisplayActivityTest.java +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/FileDisplayActivityTest.java @@ -27,8 +27,8 @@ public class FileDisplayActivityTest extends AbstractIT { InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { Activity activity = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(RESUMED).iterator().next(); - if (activity instanceof WhatsNewActivity) { - activity.onBackPressed(); + if (activity instanceof WhatsNewActivity whatsNewActivity) { + whatsNewActivity.getOnBackPressedDispatcher().onBackPressed(); } }); scenario.recreate(); 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 index 7fa76a8..77c204e 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailSharingFragmentIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailSharingFragmentIT.kt @@ -822,7 +822,7 @@ class FileDetailSharingFragmentIT : AbstractIT() { val processFragment = activity.supportFragmentManager.findFragmentByTag(FileDetailsSharingProcessFragment.TAG) as FileDetailsSharingProcessFragment - processFragment.onBackPressed() + processFragment.activity?.onBackPressedDispatcher?.onBackPressed() } } } 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 index 8881262..0fa3bc4 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFragmentIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFragmentIT.kt @@ -46,7 +46,7 @@ class UnifiedSearchFragmentIT : AbstractIT() { scenario.onActivity { activity -> onIdleSync { EspressoIdlingResource.increment() - val sut = UnifiedSearchFragment.newInstance(null, null) + val sut = UnifiedSearchFragment.newInstance(null, null, "/") activity.addFragment(sut) sut.onSearchResultChanged( @@ -83,7 +83,7 @@ class UnifiedSearchFragmentIT : AbstractIT() { onIdleSync { EspressoIdlingResource.increment() - val sut = UnifiedSearchFragment.newInstance(null, null) + val sut = UnifiedSearchFragment.newInstance(null, null, "/") val testViewModel = UnifiedSearchViewModel(activity.application) testViewModel.setConnectivityService(activity.connectivityServiceMock) val localRepository = UnifiedSearchFakeRepository() 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 index 66b164b..c27e104 100644 --- a/app/src/androidTest/java/com/owncloud/android/utils/theme/CapabilityUtilsIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/utils/theme/CapabilityUtilsIT.kt @@ -32,6 +32,14 @@ class CapabilityUtilsIT : AbstractIT() { assertTrue(test(OwnCloudVersion.nextcloud_20)) } - private fun test(version: OwnCloudVersion): Boolean = - CapabilityUtils.checkOutdatedWarning(targetContext.resources, version, false) + @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/gplay/java/com/owncloud/android/utils/PushUtils.java b/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java index 816612e..4e77b40 100644 --- a/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java +++ b/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java @@ -44,6 +44,7 @@ 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; @@ -96,7 +97,11 @@ public final class PushUtils { if (!new File(privateKeyPath).exists() && !new File(publicKeyPath).exists()) { try { if (!keyPathFile.exists()) { - keyPathFile.mkdir(); + 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); @@ -304,8 +309,12 @@ public final class PushUtils { try { if (!new File(path).exists()) { File newFile = new File(path); - newFile.getParentFile().mkdirs(); - newFile.createNewFile(); + 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); diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7d0bf64..4154b1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - - - + + @@ -352,6 +370,7 @@ - + @@ -584,6 +603,10 @@ + + + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +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.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.component.AddTaskAlertDialog +import com.nextcloud.client.assistant.extensions.getInputTitle +import com.nextcloud.client.assistant.model.ScreenOverlayState +import com.nextcloud.client.assistant.model.ScreenState +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.ui.composeActivity.ComposeActivity +import com.nextcloud.ui.composeComponents.alertDialog.SimpleAlertDialog +import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet +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.delay +import kotlinx.coroutines.launch + +private const val PULL_TO_REFRESH_DELAY = 1500L + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AssistantScreen(viewModel: AssistantViewModel, capability: OCCapability, activity: Activity) { + val messageId by viewModel.snackbarMessageId.collectAsState() + val screenOverlayState by viewModel.screenOverlayState.collectAsState() + + val selectedTaskType by viewModel.selectedTaskType.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() } + + LaunchedEffect(messageId) { + messageId?.let { + snackbarHostState.showSnackbar(activity.getString(it)) + viewModel.updateSnackbarMessage(null) + } + } + + LaunchedEffect(Unit) { + viewModel.startTaskListPolling() + } + + DisposableEffect(Unit) { + onDispose { + viewModel.stopTaskListPolling() + } + } + + Scaffold( + modifier = Modifier.pullToRefresh( + false, + pullRefreshState, + onRefresh = { + scope.launch { + delay(PULL_TO_REFRESH_DELAY) + viewModel.fetchTaskList() + } + } + ), + topBar = { + taskTypes?.let { + TaskTypesRow(selectedTaskType, data = it) { task -> + viewModel.selectTaskType(task) + } + } + }, + floatingActionButton = { + if (!taskTypes.isNullOrEmpty()) { + AddTaskButton( + selectedTaskType, + viewModel + ) + } + }, + floatingActionButtonPosition = FabPosition.EndOverlay, + snackbarHost = { + SnackbarHost(snackbarHostState) + } + ) { paddingValues -> + when (screenState) { + is ScreenState.EmptyContent -> { + val state = (screenState as ScreenState.EmptyContent) + EmptyContent( + paddingValues, + state.iconId, + state.descriptionId + ) + } + + ScreenState.Content -> { + AssistantContent( + paddingValues, + filteredTaskList ?: listOf(), + viewModel, + capability + ) + } + + else -> EmptyContent( + paddingValues, + R.drawable.spinner_inner, + R.string.assistant_screen_loading + ) + } + + LinearProgressIndicator( + progress = { pullRefreshState.distanceFraction }, + modifier = Modifier.fillMaxWidth() + ) + + OverlayState(screenOverlayState, activity, viewModel) + } +} + +@Composable +private fun AddTaskButton(selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) { + FloatingActionButton( + onClick = { + selectedTaskType?.let { + val newState = ScreenOverlayState.AddTask(it, "") + viewModel.updateTaskListScreenState(newState) + } + } + ) { + Icon(Icons.Filled.Add, "Add Task Icon") + } +} + +@Suppress("LongMethod") +@Composable +private fun OverlayState(state: ScreenOverlayState?, activity: Activity, viewModel: AssistantViewModel) { + when (state) { + is ScreenOverlayState.AddTask -> { + AddTaskAlertDialog( + title = state.taskType.name, + description = state.taskType.description, + defaultInput = state.input, + addTask = { input -> + state.taskType.let { taskType -> + viewModel.createTask(input = input, taskType = taskType) + } + }, + dismiss = { + viewModel.updateTaskListScreenState(null) + } + ) + } + + 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), + dismiss = { viewModel.updateTaskListScreenState(null) }, + onComplete = { viewModel.deleteTask(state.id) } + ) + } + + is ScreenOverlayState.TaskActions -> { + val actions = state.getActions(activity, onEditCompleted = { addTask -> + viewModel.updateTaskListScreenState(addTask) + }, onDeleteCompleted = { deleteTask -> + viewModel.updateTaskListScreenState(deleteTask) + }) + + MoreActionsBottomSheet( + title = state.task.getInputTitle(), + actions = actions, + dismiss = { viewModel.updateTaskListScreenState(null) } + ) + } + + else -> Unit + } +} + +@Composable +private fun AssistantContent( + 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, + capability, + showTaskActions = { + val newState = ScreenOverlayState.TaskActions(task) + viewModel.updateTaskListScreenState(newState) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun EmptyContent(paddingValues: PaddingValues, iconId: Int?, descriptionId: 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 = "empty content icon" + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + Text( + text = stringResource(descriptionId), + fontSize = 18.sp, + textAlign = TextAlign.Center, + color = colorResource(R.color.text_color) + ) + } +} + +@Suppress("MagicNumber") +@Composable +@Preview +private fun AssistantScreenPreview() { + MaterialTheme( + content = { + AssistantScreen( + viewModel = getMockViewModel(false), + activity = ComposeActivity(), + capability = OCCapability().apply { + versionMayor = 30 + } + ) + } + ) +} + +@Suppress("MagicNumber") +@Composable +@Preview +private fun AssistantEmptyScreenPreview() { + MaterialTheme( + content = { + AssistantScreen( + viewModel = getMockViewModel(true), + activity = ComposeActivity(), + capability = OCCapability().apply { + versionMayor = 30 + } + ) + } + ) +} + +private fun getMockViewModel(giveEmptyTasks: Boolean): AssistantViewModel { + val mockLocalRepository = MockAssistantLocalRepository() + val mockRemoteRepository = MockAssistantRemoteRepository(giveEmptyTasks) + return AssistantViewModel( + accountName = "test:localhost", + remoteRepository = mockRemoteRepository, + localRepository = mockLocalRepository + ) +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt index 6d123e2..6f64bb6 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt @@ -11,18 +11,31 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.client.assistant.model.ScreenOverlayState import com.nextcloud.client.assistant.model.ScreenState -import com.nextcloud.client.assistant.repository.AssistantRepositoryType +import com.nextcloud.client.assistant.repository.local.AssistantLocalRepository +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 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 AssistantViewModel(private val repository: AssistantRepositoryType) : ViewModel() { +class AssistantViewModel( + private val accountName: String, + private val remoteRepository: AssistantRemoteRepository, + private val localRepository: AssistantLocalRepository +) : ViewModel() { + + companion object { + private const val TAG = "AssistantViewModel" + private const val TASK_LIST_POLLING_INTERVAL_MS = 15_000L + } private val _screenState = MutableStateFlow(null) val screenState: StateFlow = _screenState @@ -44,14 +57,54 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View private val _filteredTaskList = MutableStateFlow?>(null) val filteredTaskList: StateFlow?> = _filteredTaskList + private var taskPollingJob: Job? = null + init { fetchTaskTypes() } + // region task polling + fun startTaskListPolling() { + stopTaskListPolling() + + taskPollingJob = viewModelScope.launch(Dispatchers.IO) { + try { + while (isActive) { + Log_OC.d(TAG, "Polling task list...") + fetchTaskListSuspending() + delay(TASK_LIST_POLLING_INTERVAL_MS) + } + } finally { + Log_OC.d(TAG, "Polling coroutine cancelled") + } + } + } + + fun stopTaskListPolling() { + taskPollingJob?.cancel() + taskPollingJob = null + } + // endregion + + private suspend fun fetchTaskListSuspending() { + val cachedTasks = localRepository.getCachedTasks(accountName) + if (cachedTasks.isNotEmpty()) { + _filteredTaskList.value = cachedTasks.sortedByDescending { it.id } + } + + val taskType = _selectedTaskType.value?.id ?: return + val result = remoteRepository.getTaskList(taskType) + if (result != null) { + taskList = result + _filteredTaskList.value = taskList?.sortedByDescending { it.id } + localRepository.cacheTasks(result, accountName) + } + } + @Suppress("MagicNumber") fun createTask(input: String, taskType: TaskTypeData) { viewModelScope.launch(Dispatchers.IO) { - val result = repository.createTask(input, taskType) + val result = remoteRepository.createTask(input, taskType) val messageId = if (result.isSuccess) { R.string.assistant_screen_task_create_success_message @@ -76,15 +129,11 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View private fun fetchTaskTypes() { viewModelScope.launch(Dispatchers.IO) { - val taskTypesResult = repository.getTaskTypes() - - if (taskTypesResult == null) { - updateSnackbarMessage(R.string.assistant_screen_task_types_error_state_message) - return@launch - } - - if (taskTypesResult.isEmpty()) { - updateSnackbarMessage(R.string.assistant_screen_task_list_empty_message) + val taskTypesResult = remoteRepository.getTaskTypes() + if (taskTypesResult == null || taskTypesResult.isEmpty()) { + _screenState.update { + ScreenState.emptyTaskTypes() + } return@launch } @@ -98,12 +147,17 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View fun fetchTaskList() { viewModelScope.launch(Dispatchers.IO) { - _screenState.update { - ScreenState.Refreshing + // Try cached data first + val cachedTasks = localRepository.getCachedTasks(accountName) + if (cachedTasks.isNotEmpty()) { + _filteredTaskList.update { + cachedTasks.sortedByDescending { it.id } + } + updateTaskListScreenState() } val taskType = _selectedTaskType.value?.id ?: return@launch - val result = repository.getTaskList(taskType) + val result = remoteRepository.getTaskList(taskType) if (result != null) { taskList = result _filteredTaskList.update { @@ -111,19 +165,21 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View task.id } } + + localRepository.cacheTasks(result, accountName) updateSnackbarMessage(null) } else { updateSnackbarMessage(R.string.assistant_screen_task_list_error_state_message) } - updateScreenState() + updateTaskListScreenState() } } - private fun updateScreenState() { + private fun updateTaskListScreenState() { _screenState.update { if (_filteredTaskList.value?.isEmpty() == true) { - ScreenState.EmptyContent + ScreenState.emptyTaskList() } else { ScreenState.Content } @@ -132,7 +188,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View fun deleteTask(id: Long) { viewModelScope.launch(Dispatchers.IO) { - val result = repository.deleteTask(id) + val result = remoteRepository.deleteTask(id) val messageId = if (result.isSuccess) { R.string.assistant_screen_task_delete_success_message @@ -144,6 +200,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View if (result.isSuccess) { removeTaskFromList(id) + localRepository.deleteTask(id, accountName) } } } @@ -154,7 +211,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View } } - fun updateScreenState(value: ScreenOverlayState?) { + fun updateTaskListScreenState(value: ScreenOverlayState?) { _screenOverlayState.update { value } diff --git a/app/src/main/java/com/nextcloud/client/assistant/model/ScreenState.kt b/app/src/main/java/com/nextcloud/client/assistant/model/ScreenState.kt index 33e206c..3c2ba4d 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/model/ScreenState.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/model/ScreenState.kt @@ -7,8 +7,24 @@ package com.nextcloud.client.assistant.model -enum class ScreenState { - Refreshing, - EmptyContent, - Content +import com.owncloud.android.R + +sealed class ScreenState { + data object Loading : ScreenState() + + data object Content : ScreenState() + + data class EmptyContent(val iconId: Int?, val descriptionId: Int) : ScreenState() + + companion object { + fun emptyTaskTypes(): ScreenState = EmptyContent( + descriptionId = R.string.assistant_screen_task_list_empty_warning, + iconId = null + ) + + fun emptyTaskList(): ScreenState = EmptyContent( + descriptionId = R.string.assistant_screen_create_a_new_task_from_bottom_right_text, + iconId = null + ) + } } 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 0000000..070c0c7 --- /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): List + suspend fun insertTask(task: Task, accountName: String) + suspend fun deleteTask(id: Long, accountName: 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 0000000..ef6ba93 --- /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): List { + val entities = assistantDao.getAssistantTasksByAccount(accountName) + 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) { + val cached = assistantDao.getAssistantTasksByAccount(accountName).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 0000000..c09065a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/local/MockAssistantLocalRepository.kt @@ -0,0 +1,35 @@ +/* + * 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): 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) { + 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 0000000..c46245b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepository.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.repository.remote + +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData + +interface AssistantRemoteRepository { + fun getTaskTypes(): List? + + fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult + + fun getTaskList(taskType: String): List? + + fun deleteTask(id: Long): 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 0000000..07545a3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepositoryImpl.kt @@ -0,0 +1,80 @@ +/* + * 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.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +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.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.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OCCapability + +class AssistantRemoteRepositoryImpl(private val client: NextcloudClient, capability: OCCapability) : + AssistantRemoteRepository { + + private val supportsV2 = capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30) + + @Suppress("ReturnCount") + override fun getTaskTypes(): List? { + if (supportsV2) { + val result = GetTaskTypesRemoteOperationV2().execute(client) + if (result.isSuccess) { + return result.resultData + } + } else { + val result = GetTaskTypesRemoteOperationV1().execute(client) + if (result.isSuccess) { + return result.resultData.toV2() + } + } + + return null + } + + override fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult = if (supportsV2) { + CreateTaskRemoteOperationV2(input, taskType).execute(client) + } else { + if (taskType.id.isNullOrEmpty()) { + RemoteOperationResult(ResultCode.CANCELLED) + } else { + CreateTaskRemoteOperationV1(input, taskType.id!!).execute(client) + } + } + + @Suppress("ReturnCount") + override fun getTaskList(taskType: String): List? { + if (supportsV2) { + val result = GetTaskListRemoteOperationV2(taskType).execute(client) + if (result.isSuccess) { + return result.resultData.tasks.filter { it.appId == "assistant" } + } + } else { + val result = GetTaskListRemoteOperationV1("assistant").execute(client) + if (result.isSuccess) { + return result.resultData.toV2().tasks.filter { it.type == taskType } + } + } + + return null + } + + override fun deleteTask(id: Long): RemoteOperationResult = if (supportsV2) { + DeleteTaskRemoteOperationV2(id).execute(client) + } else { + DeleteTaskRemoteOperationV1(id).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 0000000..b7acd88 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/MockAssistantRemoteRepository.kt @@ -0,0 +1,67 @@ +/* + * 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.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 + +@Suppress("MagicNumber") +class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false) : AssistantRemoteRepository { + override fun getTaskTypes(): 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 fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult = + RemoteOperationResult(RemoteOperationResult.ResultCode.OK) + + override 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 fun deleteTask(id: Long): RemoteOperationResult = + RemoteOperationResult(RemoteOperationResult.ResultCode.OK) +} 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 index 4438714..a52b114 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt @@ -8,7 +8,9 @@ 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 @@ -17,6 +19,8 @@ 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.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -28,9 +32,11 @@ 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.tooling.preview.Preview @@ -54,29 +60,54 @@ fun TaskDetailBottomSheet(task: Task, showTaskActions: () -> Unit, dismiss: () - onDismissRequest = { dismiss() }, sheetState = sheetState ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - stickyHeader { - Row( - modifier = Modifier.fillMaxWidth() - ) { - Spacer(modifier = Modifier.weight(1f)) + 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 = "More button", - tint = colorResource(R.color.text_color) - ) + IconButton(onClick = showTaskActions) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "More button", + tint = colorResource(R.color.text_color) + ) + } } } + + item { + InputOutputCard(task) + } } - 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 = "assistant icon", + modifier = Modifier.size(12.dp) + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = stringResource(R.string.assistant_generation_warning), + color = colorResource(R.color.text_color), + fontSize = 12.sp + ) } } } 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 index fa99d73..3e0e9ad 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt @@ -8,10 +8,9 @@ package com.nextcloud.client.assistant.taskTypes import android.annotation.SuppressLint -import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults -import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -26,13 +25,13 @@ import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData fun TaskTypesRow(selectedTaskType: TaskTypeData?, data: List, selectTaskType: (TaskTypeData) -> Unit) { val selectedTabIndex = data.indexOfFirst { it.id == selectedTaskType?.id }.takeIf { it >= 0 } ?: 0 - ScrollableTabRow( + PrimaryScrollableTabRow( selectedTabIndex = selectedTabIndex, edgePadding = 0.dp, containerColor = colorResource(R.color.actionbar_color), indicator = { TabRowDefaults.SecondaryIndicator( - Modifier.tabIndicatorOffset(it[selectedTabIndex]), + Modifier.tabIndicatorOffset(selectedTabIndex), color = colorResource(R.color.primary) ) } diff --git a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt index 25ef645..87ce9c9 100644 --- a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt +++ b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt @@ -16,11 +16,15 @@ 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.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.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 @@ -37,6 +41,7 @@ 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( @@ -51,7 +56,8 @@ import com.owncloud.android.db.ProviderMeta UploadEntity::class, VirtualEntity::class, OfflineOperationEntity::class, - RecommendedFileEntity::class + RecommendedFileEntity::class, + AssistantEntity::class ], version = ProviderMeta.DB_VERSION, autoMigrations = [ @@ -81,7 +87,10 @@ import com.owncloud.android.db.ProviderMeta 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 = 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) ], exportSchema = true ) @@ -94,6 +103,9 @@ abstract class NextcloudDatabase : RoomDatabase() { abstract fun offlineOperationDao(): OfflineOperationDao abstract fun uploadDao(): UploadDao abstract fun recommendedFileDao(): RecommendedFileDao + abstract fun fileSystemDao(): FileSystemDao + abstract fun syncedFolderDao(): SyncedFolderDao + abstract fun assistantDao(): AssistantDao companion object { const val FIRST_ROOM_DB_VERSION = 65 @@ -119,5 +131,9 @@ abstract class NextcloudDatabase : RoomDatabase() { } return instance!! } + + @Suppress("DEPRECATION") + @JvmStatic + fun instance(): NextcloudDatabase = getInstance(MainApp.getAppContext()) } } 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 0000000..9ba9012 --- /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 + ORDER BY lastUpdated DESC +""" + ) + suspend fun getAssistantTasksByAccount(accountName: String): List +} 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 index 7f1c0c0..3356d69 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -17,15 +17,6 @@ import com.owncloud.android.utils.MimeType @Suppress("TooManyFunctions") @Dao interface FileDao { - @Query( - """ - SELECT DISTINCT parent - FROM filelist - WHERE path IN (:subfilePaths) - """ - ) - fun getParentIdsOfSubfiles(subfilePaths: List): List - @Update fun update(entity: FileEntity) @@ -108,4 +99,16 @@ interface FileDao { dirType: String = MimeType.DIRECTORY, webdavType: String = MimeType.WEBDAV_FOLDER ): List + + @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 } 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 0000000..fcb567a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.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.FilesystemEntity +import com.owncloud.android.db.ProviderMeta + +@Dao +interface FileSystemDao { + @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) +} 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 0000000..ddb35ff --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/SyncedFolderDao.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.Query +import com.nextcloud.client.database.entity.SyncedFolderEntity +import com.owncloud.android.db.ProviderMeta + +@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? +} 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 index aac3d1d..deaa44c 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt @@ -8,6 +8,8 @@ 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 @@ -27,4 +29,68 @@ interface UploadDao { 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 deleteByAccountAndRemotePath(accountName: 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.Companion.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( + """ + 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/AssistantEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/AssistantEntity.kt new file mode 100644 index 0000000..4c13c25 --- /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 index c3a0344..56f33b1 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt @@ -142,5 +142,9 @@ data class CapabilityEntity( @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS) val defaultPermissions: Int?, @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_BUSY) - val userStatusSupportsBusy: Int? + val userStatusSupportsBusy: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES) + val isWCFEnabled: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION) + val hasValidSubscription: Int? ) 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 index bcc9d4c..18b0911 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt @@ -10,6 +10,9 @@ 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) @@ -50,3 +53,40 @@ data class SyncedFolderEntity( @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 index 1417720..ec9042c 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt @@ -1,6 +1,7 @@ /* * 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 @@ -78,3 +79,33 @@ fun UploadEntity.toOCUpload(capability: OCCapability? = null): OCUpload { return upload } + +fun OCUpload.toUploadEntity(): UploadEntity { + val id = if (uploadId == -1L) { + // needed for the insert new records to the db so that insert DAO function returns new generated id + null + } else { + 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 may overflow max int capacity since it is conversion from long to int. coerceAtMost needed + uploadEndTimestamp = uploadEndTimestamp.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + 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/device/DeviceModule.kt b/app/src/main/java/com/nextcloud/client/device/DeviceModule.kt index 74a99ef..139fbe6 100644 --- a/app/src/main/java/com/nextcloud/client/device/DeviceModule.kt +++ b/app/src/main/java/com/nextcloud/client/device/DeviceModule.kt @@ -10,7 +10,6 @@ package com.nextcloud.client.device import android.content.Context import android.os.PowerManager -import com.nextcloud.client.preferences.AppPreferences import dagger.Module import dagger.Provides @@ -18,13 +17,11 @@ import dagger.Provides class DeviceModule { @Provides - fun powerManagementService(context: Context, preferences: AppPreferences): PowerManagementService { + fun powerManagementService(context: Context): PowerManagementService { val platformPowerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager return PowerManagementServiceImpl( context = context, - platformPowerManager = platformPowerManager, - deviceInfo = DeviceInfo(), - preferences = preferences + 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 index c627bb7..730ca4e 100644 --- a/app/src/main/java/com/nextcloud/client/device/PowerManagementService.kt +++ b/app/src/main/java/com/nextcloud/client/device/PowerManagementService.kt @@ -21,14 +21,6 @@ interface PowerManagementService { */ val isPowerSavingEnabled: Boolean - /** - * Checks if the device vendor requires power saving - * exclusion workaround. - * - * @return true if workaround is required, false otherwise - */ - val isPowerSavingExclusionAvailable: Boolean - /** * Checks current battery status using platform [android.os.BatteryManager] */ diff --git a/app/src/main/java/com/nextcloud/client/device/PowerManagementServiceImpl.kt b/app/src/main/java/com/nextcloud/client/device/PowerManagementServiceImpl.kt index a6f2e91..3c8d56c 100644 --- a/app/src/main/java/com/nextcloud/client/device/PowerManagementServiceImpl.kt +++ b/app/src/main/java/com/nextcloud/client/device/PowerManagementServiceImpl.kt @@ -11,46 +11,27 @@ import android.content.Intent import android.content.IntentFilter import android.os.BatteryManager import android.os.PowerManager -import com.nextcloud.client.preferences.AppPreferences -import com.nextcloud.client.preferences.AppPreferencesImpl import com.nextcloud.utils.extensions.registerBroadcastReceiver import com.owncloud.android.datamodel.ReceiverFlag internal class PowerManagementServiceImpl( private val context: Context, - private val platformPowerManager: PowerManager, - private val preferences: AppPreferences, - private val deviceInfo: DeviceInfo = DeviceInfo() + private val platformPowerManager: PowerManager ) : PowerManagementService { companion object { - /** - * Vendors on this list use aggressive power saving methods that might - * break application experience. - */ - val OVERLY_AGGRESSIVE_POWER_SAVING_VENDORS = setOf("samsung", "huawei", "xiaomi") - @JvmStatic fun fromContext(context: Context): PowerManagementServiceImpl { val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - val preferences = AppPreferencesImpl.fromContext(context) - - return PowerManagementServiceImpl(context, powerManager, preferences, DeviceInfo()) + return PowerManagementServiceImpl(context, powerManager) } } override val isPowerSavingEnabled: Boolean get() { - if (preferences.isPowerCheckDisabled) { - return false - } - return platformPowerManager.isPowerSaveMode } - override val isPowerSavingExclusionAvailable: Boolean - get() = deviceInfo.vendor in OVERLY_AGGRESSIVE_POWER_SAVING_VENDORS - @Suppress("MagicNumber") // 100% is 100, we're not doing Cobol override val battery: BatteryStatus get() { diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.java b/app/src/main/java/com/nextcloud/client/di/AppComponent.java index 462e257..8e1f599 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppComponent.java +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.java @@ -17,6 +17,7 @@ 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; @@ -75,6 +76,8 @@ public interface AppComponent { void inject(OfflineOperationReceiver offlineOperationReceiver); + void inject(FolderDownloadWorkerReceiver folderDownloadWorkerReceiver); + @Component.Builder interface Builder { @BindsInstance diff --git a/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt b/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt index 241b0db..daf9487 100644 --- a/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt +++ b/app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt @@ -104,7 +104,7 @@ class DocumentScanActivity : true } android.R.id.home -> { - onBackPressed() + onBackPressedDispatcher.onBackPressed() true } else -> false diff --git a/app/src/main/java/com/nextcloud/client/etm/EtmActivity.kt b/app/src/main/java/com/nextcloud/client/etm/EtmActivity.kt index 9d5b72a..61fbe27 100644 --- a/app/src/main/java/com/nextcloud/client/etm/EtmActivity.kt +++ b/app/src/main/java/com/nextcloud/client/etm/EtmActivity.kt @@ -10,6 +10,7 @@ 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 @@ -46,6 +47,7 @@ class EtmActivity : onPageChanged(it) } ) + handleOnBackPressed() } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { @@ -58,11 +60,17 @@ class EtmActivity : else -> super.onOptionsItemSelected(item) } - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (!vm.onBackPressed()) { - super.onBackPressed() - } + 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?) { diff --git a/app/src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt b/app/src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt index bd0937b..513ed74 100644 --- a/app/src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt +++ b/app/src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt @@ -6,6 +6,7 @@ */ package com.nextcloud.client.etm +import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.View @@ -20,6 +21,7 @@ class EtmMenuAdapter(context: Context, val onItemClicked: (Int) -> Unit) : private val layoutInflater = LayoutInflater.from(context) var pages: List = listOf() + @SuppressLint("NotifyDataSetChanged") set(value) { field = value notifyDataSetChanged() 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 index 0e72ba8..7af132b 100644 --- a/app/src/main/java/com/nextcloud/client/etm/pages/EtmFileTransferFragment.kt +++ b/app/src/main/java/com/nextcloud/client/etm/pages/EtmFileTransferFragment.kt @@ -6,6 +6,7 @@ */ package com.nextcloud.client.etm.pages +import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -63,6 +64,7 @@ class EtmFileTransferFragment : EtmBaseFragment() { private var transfers = listOf() + @SuppressLint("NotifyDataSetChanged") fun setStatus(status: TransferManager.Status) { transfers = listOf(status.pending, status.running, status.completed).flatten().reversed() notifyDataSetChanged() diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index f0d58d9..401fee1 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -17,14 +17,17 @@ import androidx.work.WorkerFactory import androidx.work.WorkerParameters import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.core.Clock -import com.nextcloud.client.device.DeviceInfo +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.AutoUploadWorker +import com.nextcloud.client.jobs.autoUpload.FileSystemRepository import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.metadata.MetadataWorker import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker +import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.logger.Logger import com.nextcloud.client.network.ConnectivityService @@ -50,7 +53,6 @@ class BackgroundJobFactory @Inject constructor( private val clock: Clock, private val powerManagementService: PowerManagementService, private val backgroundJobManager: Provider, - private val deviceInfo: DeviceInfo, private val accountManager: UserAccountManager, private val resources: Resources, private val arbitraryDataProvider: ArbitraryDataProvider, @@ -62,7 +64,8 @@ class BackgroundJobFactory @Inject constructor( private val viewThemeUtils: Provider, private val localBroadcastManager: Provider, private val generatePdfUseCase: GeneratePDFUseCase, - private val syncedFolderProvider: SyncedFolderProvider + private val syncedFolderProvider: SyncedFolderProvider, + private val database: NextcloudDatabase ) : WorkerFactory() { @SuppressLint("NewApi") @@ -84,7 +87,7 @@ class BackgroundJobFactory @Inject constructor( when (workerClass) { ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters) ContactsImportWork::class -> createContactsImportWork(context, workerParameters) - FilesSyncWork::class -> createFilesSyncWork(context, workerParameters) + AutoUploadWorker::class -> createFilesSyncWork(context, workerParameters) OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters) MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters) NotificationWork::class -> createNotificationWork(context, workerParameters) @@ -100,6 +103,7 @@ class BackgroundJobFactory @Inject constructor( 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 } } @@ -166,16 +170,16 @@ class BackgroundJobFactory @Inject constructor( contentResolver ) - private fun createFilesSyncWork(context: Context, params: WorkerParameters): FilesSyncWork = FilesSyncWork( + private fun createFilesSyncWork(context: Context, params: WorkerParameters): AutoUploadWorker = AutoUploadWorker( context = context, params = params, - contentResolver = contentResolver, userAccountManager = accountManager, uploadsStorageManager = uploadsStorageManager, connectivityService = connectivityService, powerManagementService = powerManagementService, syncedFolderProvider = syncedFolderProvider, - backgroundJobManager = backgroundJobManager.get() + backgroundJobManager = backgroundJobManager.get(), + repository = FileSystemRepository(dao = database.fileSystemDao()) ) private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork = OfflineSyncWork( @@ -285,4 +289,12 @@ class BackgroundJobFactory @Inject constructor( params, accountManager.user ) + + private fun createFolderDownloadWorker(context: Context, params: WorkerParameters): FolderDownloadWorker = + FolderDownloadWorker( + accountManager, + context, + viewThemeUtils.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 index 2f9d311..219de80 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -10,6 +10,7 @@ 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 /** @@ -119,15 +120,12 @@ interface BackgroundJobManager { fun startImmediateFilesExportJob(files: Collection): LiveData - fun schedulePeriodicFilesSyncJob(syncedFolderID: Long) + fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder) - /** - * Immediately start File Sync job for given syncFolderID. - */ - fun startImmediateFilesSyncJob( - syncedFolderID: Long, + fun startAutoUploadImmediately( + syncedFolder: SyncedFolder, overridePowerSaving: Boolean = false, - changedFiles: Array = arrayOf() + contentUris: Array = arrayOf() ) fun cancelTwoWaySyncJob() @@ -142,12 +140,10 @@ interface BackgroundJobManager { fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) fun getFileUploads(user: User): LiveData> fun cancelFilesUploadJob(user: User) - fun isStartFileUploadJobScheduled(user: User): Boolean + fun isStartFileUploadJobScheduled(accountName: String): Boolean fun cancelFilesDownloadJob(user: User, fileId: Long) - fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean - @Suppress("LongParameterList") fun startFileDownloadJob( user: User, @@ -175,4 +171,6 @@ interface BackgroundJobManager { 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 index 5fd6b4f..d476a92 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -26,7 +26,9 @@ 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 @@ -35,6 +37,7 @@ import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.utils.extensions.isWorkRunning import com.nextcloud.utils.extensions.isWorkScheduled import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolder import com.owncloud.android.operations.DownloadType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -91,6 +94,7 @@ internal class BackgroundJobManagerImpl( 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" @@ -472,41 +476,68 @@ internal class BackgroundJobManagerImpl( ) } - override fun schedulePeriodicFilesSyncJob(syncedFolderID: Long) { + override fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder) { + val syncedFolderID = syncedFolder.id + val arguments = Data.Builder() - .putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID) + .putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID) + .build() + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresCharging(syncedFolder.isChargingOnly) .build() val request = periodicRequestBuilder( - jobClass = FilesSyncWork::class, + jobClass = AutoUploadWorker::class, jobName = JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID, - intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES + intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES, + constraints = constraints ) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + DEFAULT_BACKOFF_CRITERIA_DELAY_SEC, + TimeUnit.SECONDS + ) .setInputData(arguments) .build() + workManager.enqueueUniquePeriodicWork( JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID, - ExistingPeriodicWorkPolicy.REPLACE, + ExistingPeriodicWorkPolicy.KEEP, request ) } - override fun startImmediateFilesSyncJob( - syncedFolderID: Long, + override fun startAutoUploadImmediately( + syncedFolder: SyncedFolder, overridePowerSaving: Boolean, - changedFiles: Array + contentUris: Array ) { + val syncedFolderID = syncedFolder.id + val arguments = Data.Builder() - .putBoolean(FilesSyncWork.OVERRIDE_POWER_SAVING, overridePowerSaving) - .putStringArray(FilesSyncWork.CHANGED_FILES, changedFiles) - .putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID) + .putBoolean(AutoUploadWorker.OVERRIDE_POWER_SAVING, overridePowerSaving) + .putStringArray(AutoUploadWorker.CONTENT_URIS, contentUris) + .putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID) + .build() + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresCharging(syncedFolder.isChargingOnly) .build() val request = oneTimeRequestBuilder( - jobClass = FilesSyncWork::class, + 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( @@ -606,10 +637,10 @@ internal class BackgroundJobManagerImpl( workManager.enqueue(request) } - private fun startFileUploadJobTag(user: User): String = JOB_FILES_UPLOAD + user.accountName + private fun startFileUploadJobTag(accountName: String): String = JOB_FILES_UPLOAD + accountName - override fun isStartFileUploadJobScheduled(user: User): Boolean = - workManager.isWorkScheduled(startFileUploadJobTag(user)) + override fun isStartFileUploadJobScheduled(accountName: String): Boolean = + workManager.isWorkScheduled(startFileUploadJobTag(accountName)) /** * This method supports initiating uploads for various scenarios, including: @@ -627,7 +658,7 @@ internal class BackgroundJobManagerImpl( defaultDispatcherScope.launch { val batchSize = FileUploadHelper.MAX_FILE_COUNT val batches = uploadIds.toList().chunked(batchSize) - val tag = startFileUploadJobTag(user) + val tag = startFileUploadJobTag(user.accountName) val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -673,9 +704,6 @@ internal class BackgroundJobManagerImpl( private fun startFileDownloadJobTag(user: User, fileId: Long): String = JOB_FOLDER_DOWNLOAD + user.accountName + fileId - override fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean = - workManager.isWorkScheduled(startFileDownloadJobTag(user, fileId)) - override fun startFileDownloadJob( user: User, file: OCFile, @@ -795,4 +823,28 @@ internal class BackgroundJobManagerImpl( 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) + .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/ContentObserverWork.kt b/app/src/main/java/com/nextcloud/client/jobs/ContentObserverWork.kt index d0394eb..6d421d2 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/ContentObserverWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/ContentObserverWork.kt @@ -1,18 +1,27 @@ /* * 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.app.Notification import android.content.Context -import androidx.work.Worker +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.utils.ForegroundServiceHelper +import com.owncloud.android.R +import com.owncloud.android.datamodel.ForegroundServiceType import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.notifications.NotificationUtils 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. @@ -21,53 +30,113 @@ import com.owncloud.android.utils.FilesSyncHelper * * This job must not be started on API < 24. */ +@Suppress("TooGenericExceptionCaught") class ContentObserverWork( - appContext: Context, + private val context: Context, private val params: WorkerParameters, private val syncedFolderProvider: SyncedFolderProvider, private val powerManagementService: PowerManagementService, private val backgroundJobManager: BackgroundJobManager -) : Worker(appContext, params) { +) : CoroutineWorker(context, params) { - override fun doWork(): Result { - backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class)) - - if (params.triggeredContentUris.isNotEmpty()) { - Log_OC.d(TAG, "File-sync Content Observer detected files change") - checkAndStartFileSyncJob() - backgroundJobManager.startMediaFoldersDetectionJob() - } else { - Log_OC.d(TAG, "triggeredContentUris empty") - } - recheduleSelf() - - val result = Result.success() - backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result) - return result + companion object { + private const val TAG = "🔍" + "ContentObserverWork" + private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER + private const val NOTIFICATION_ID = 774 } - private fun recheduleSelf() { + 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.") + + val notificationTitle = context.getString(R.string.content_observer_work_notification_title) + val notification = createNotification(notificationTitle) + updateForegroundInfo(notification) + 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.") + } + + rescheduleSelf() + + 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() + } + } + + private suspend fun updateForegroundInfo(notification: Notification) { + val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( + NOTIFICATION_ID, + notification, + ForegroundServiceType.DataSync + ) + setForeground(foregroundInfo) + } + + private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setSmallIcon(R.drawable.ic_find_in_page) + .setOngoing(true) + .setSound(null) + .setVibrate(null) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSilent(true) + .build() + + /** + * Re-schedules this observer to ensure continuous monitoring of media changes. + */ + private fun rescheduleSelf() { + Log_OC.d(TAG, "🔁 Rescheduling ContentObserverWork for continued observation.") backgroundJobManager.scheduleContentObserverJob() } - private fun checkAndStartFileSyncJob() { - if (!powerManagementService.isPowerSavingEnabled && syncedFolderProvider.countEnabledSyncedFolders() > 0) { - val changedFiles = mutableListOf() - for (uri in params.triggeredContentUris) { - changedFiles.add(uri.toString()) - } - FilesSyncHelper.startFilesSyncForAllFolders( + 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") + + try { + FilesSyncHelper.startAutoUploadImmediatelyWithContentUris( syncedFolderProvider, backgroundJobManager, false, - changedFiles.toTypedArray() + contentUris ) - } else { - Log_OC.w(TAG, "cant startFilesSyncForAllFolders") + 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) } } - - companion object { - val TAG: String = ContentObserverWork::class.java.simpleName - } } 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 0000000..a43b524 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadHelper.kt @@ -0,0 +1,135 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import com.nextcloud.utils.extensions.shouldSkipFile +import com.nextcloud.utils.extensions.toLocalPath +import com.owncloud.android.datamodel.FilesystemDataProvider +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 { + companion object { + private const val TAG = "AutoUploadHelper" + private const val MAX_DEPTH = 100 + } + + fun insertCustomFolderIntoDB(folder: SyncedFolder, filesystemDataProvider: FilesystemDataProvider?): 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() + + if (folder.shouldSkipFile(javaFile, lastModified, creationTime)) { + skipCount++ + return FileVisitResult.CONTINUE + } + + val localPath = file.toLocalPath() + + filesystemDataProvider?.storeOrUpdateFileValue( + localPath, + lastModified, + javaFile.isDirectory, + 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/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt new file mode 100644 index 0000000..2bef1d4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -0,0 +1,480 @@ +/* + * 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.app.NotificationManager +import android.content.Context +import android.content.res.Resources +import androidx.core.app.NotificationCompat +import androidx.exifinterface.media.ExifInterface +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +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.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.SubFolderRule +import com.nextcloud.utils.ForegroundServiceHelper +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.ForegroundServiceType +import com.owncloud.android.datamodel.MediaFolderType +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.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.activity.SettingsActivity +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.FilesSyncHelper +import com.owncloud.android.utils.MimeType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@Suppress("LongParameterList", "TooManyFunctions") +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 backgroundJobManager: BackgroundJobManager, + private val repository: FileSystemRepository +) : CoroutineWorker(context, params) { + + companion object { + const val TAG = "🔄📤" + "AutoUpload" + const val OVERRIDE_POWER_SAVING = "overridePowerSaving" + const val CONTENT_URIS = "content_uris" + const val SYNCED_FOLDER_ID = "syncedFolderId" + private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD + + private const val NOTIFICATION_ID = 266 + } + + private val helper = AutoUploadHelper() + private lateinit var syncedFolder: SyncedFolder + private val notificationManager by lazy { + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + @Suppress("TooGenericExceptionCaught", "ReturnCount") + override suspend fun doWork(): Result { + return try { + val syncFolderId = inputData.getLong(SYNCED_FOLDER_ID, -1) + syncedFolder = syncedFolderProvider.getSyncedFolderByID(syncFolderId) + ?.takeIf { it.isEnabled } ?: return Result.failure() + + // initial notification + val notification = createNotification(context.getString(R.string.upload_files)) + updateForegroundInfo(notification) + + /** + * Receives from [com.nextcloud.client.jobs.ContentObserverWork.checkAndTriggerAutoUpload] + */ + val contentUris = inputData.getStringArray(CONTENT_URIS) + + if (canExitEarly(contentUris, syncFolderId)) { + return Result.retry() + } + + collectFileChangesFromContentObserverWork(contentUris) + updateNotification() + uploadFiles(syncedFolder) + + Log_OC.d(TAG, "✅ ${syncedFolder.remotePath} finished checking files.") + Result.success() + } catch (e: Exception) { + Log_OC.e(TAG, "❌ failed: ${e.message}") + Result.failure() + } + } + + private fun updateNotification() { + getStartNotificationTitle()?.let { (localFolderName, remoteFolderName) -> + val startNotification = createNotification( + context.getString( + R.string.auto_upload_worker_start_text, + localFolderName, + remoteFolderName + ) + ) + + notificationManager.notify(NOTIFICATION_ID, startNotification) + } + } + + private suspend fun updateForegroundInfo(notification: Notification) { + val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( + NOTIFICATION_ID, + notification, + ForegroundServiceType.DataSync + ) + setForeground(foregroundInfo) + } + + private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID) + .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 fun canExitEarly(contentUris: Array?, 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 + } + + if (backgroundJobManager.bothFilesSyncJobsRunning(syncedFolderID)) { + Log_OC.w(TAG, "🚧 another worker is already running for $syncedFolderID") + return true + } + + val totalScanInterval = syncedFolder.getTotalScanInterval(connectivityService, powerManagementService) + val currentTime = System.currentTimeMillis() + val passedScanInterval = totalScanInterval <= currentTime + + Log_OC.d(TAG, "lastScanTimestampMs: " + syncedFolder.lastScanTimestampMs) + Log_OC.d(TAG, "totalScanInterval: $totalScanInterval") + Log_OC.d(TAG, "currentTime: $currentTime") + Log_OC.d(TAG, "passedScanInterval: $passedScanInterval") + + if (!passedScanInterval && contentUris.isNullOrEmpty() && !overridePowerSaving) { + Log_OC.w( + TAG, + "skipped since started before scan interval and nothing todo: " + syncedFolder.localPath + ) + return true + } + + return false + } + + /** + * Instead of scanning the entire local folder, optional content URIs can be passed to the worker + * to detect only the relevant changes. + */ + @Suppress("MagicNumber", "TooGenericExceptionCaught") + private suspend fun collectFileChangesFromContentObserverWork(contentUris: Array?) = try { + withContext(Dispatchers.IO) { + if (contentUris.isNullOrEmpty()) { + FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper) + } else { + val isContentUrisStored = FilesSyncHelper.insertChangedEntries(syncedFolder, contentUris) + if (!isContentUrisStored) { + Log_OC.w( + TAG, + "changed content uris not stored, fallback to insert all db entries to not lose files" + ) + + FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper) + } + } + syncedFolder.lastScanTimestampMs = System.currentTimeMillis() + syncedFolderProvider.updateSyncFolder(syncedFolder) + } + } catch (e: Exception) { + Log_OC.d(TAG, "Exception collectFileChangesFromContentObserverWork: $e") + } + + private fun prepareDateFormat(): SimpleDateFormat { + val currentLocale = context.resources.configuration.locales[0] + return SimpleDateFormat("yyyy:MM:dd HH:mm:ss", currentLocale).apply { + timeZone = TimeZone.getTimeZone(TimeZone.getDefault().id) + } + } + + 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 = 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 dateFormat = prepareDateFormat() + val user = getUserOrReturn(syncedFolder) ?: return@withContext + val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(ocAccount, context) + val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light) + val currentLocale = context.resources.configuration.locales[0] + + var lastId = 0 + while (true) { + 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, "Processing batch: lastId=$lastId, count=${filePathsWithIds.size}") + + filePathsWithIds.forEach { (path, id) -> + val file = File(path) + val localPath = file.absolutePath + val remotePath = getRemotePath( + file, + syncedFolder, + dateFormat, + lightVersion, + context.resources, + currentLocale + ) + + try { + var (uploadEntity, upload) = createEntityAndUpload(user, localPath, remotePath) + try { + // Insert/update to IN_PROGRESS state before starting upload + val generatedId = uploadsStorageManager.uploadDao.insertOrReplace(uploadEntity) + uploadEntity = uploadEntity.copy(id = generatedId.toInt()) + upload.uploadId = generatedId + + val operation = createUploadFileOperation(upload, user) + Log_OC.d(TAG, "🕒 uploading: $localPath, id: $generatedId") + + val result = operation.execute(client) + uploadsStorageManager.updateStatus(uploadEntity, result.isSuccess) + + if (result.isSuccess) { + repository.markFileAsUploaded(localPath, syncedFolder) + Log_OC.d(TAG, "✅ upload completed: $localPath") + } else { + Log_OC.e( + TAG, + "❌ upload failed $localPath (${upload.accountName}): ${result.logMessage}" + ) + } + } catch (e: Exception) { + uploadsStorageManager.updateStatus( + uploadEntity, + UploadsStorageManager.UploadStatus.UPLOAD_FAILED + ) + Log_OC.e( + TAG, + "Exception during upload file, localPath: $localPath, remotePath: $remotePath," + + " exception: $e" + ) + } + } catch (e: Exception) { + Log_OC.e( + TAG, + "Exception uploadFiles during creating entity and upload, localPath: $localPath, " + + "remotePath: $remotePath, exception: $e" + ) + } + + // update last id so upload can continue where it left + lastId = id + } + } + } + + private fun createEntityAndUpload(user: User, localPath: String, remotePath: String): Pair { + 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 = uploadsStorageManager.uploadDao.getUploadByAccountAndPaths( + localPath = localPath, + remotePath = remotePath, + accountName = user.accountName + ) + + val upload = ( + uploadEntity?.toOCUpload(null) ?: OCUpload( + localPath, + remotePath, + user.accountName + ) + ).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 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 getRemotePath( + file: File, + syncedFolder: SyncedFolder, + sFormatter: SimpleDateFormat, + lightVersion: Boolean, + resources: Resources, + currentLocale: Locale + ): String { + val lastModificationTime = calculateLastModificationTime(file, syncedFolder, sFormatter) + + val (remoteFolder, useSubfolders, subFolderRule) = if (lightVersion) { + Triple( + resources.getString(R.string.syncedFolder_remote_folder), + resources.getBoolean(R.bool.syncedFolder_light_use_subfolders), + SubFolderRule.YEAR_MONTH + ) + } else { + Triple( + syncedFolder.remotePath, + syncedFolder.isSubfolderByDate, + syncedFolder.subfolderRule + ) + } + + return FileStorageUtils.getInstantUploadFilePath( + file, + currentLocale, + remoteFolder, + syncedFolder.localPath, + lastModificationTime, + useSubfolders, + subFolderRule + ) + } + + private fun hasExif(file: File): Boolean { + val mimeType = FileStorageUtils.getMimeTypeFromName(file.absolutePath) + return MimeType.JPEG.equals(mimeType, ignoreCase = true) || MimeType.TIFF.equals(mimeType, ignoreCase = true) + } + + @Suppress("NestedBlockDepth") + private fun calculateLastModificationTime( + file: File, + syncedFolder: SyncedFolder, + formatter: SimpleDateFormat + ): Long { + 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 getUploadAction(action: String): Int = when (action) { + "LOCAL_BEHAVIOUR_FORGET" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_FORGET + "LOCAL_BEHAVIOUR_MOVE" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_MOVE + "LOCAL_BEHAVIOUR_DELETE" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_DELETE + else -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_FORGET + } +} 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 0000000..a4bf50a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import com.nextcloud.client.database.dao.FileSystemDao +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.SyncedFolderUtils +import java.io.File + +class FileSystemRepository(private val dao: FileSystemDao) { + + companion object { + private const val TAG = "FilesystemRepository" + const val BATCH_SIZE = 50 + } + + @Suppress("NestedBlockDepth") + 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>() + + entities.forEach { + it.localPath?.let { path -> + val file = File(path) + if (!file.exists()) { + Log_OC.w(TAG, "Ignoring file for upload (doesn't exist): $path") + } else if (!SyncedFolderUtils.isQualifiedFolder(file.parent)) { + Log_OC.w(TAG, "Ignoring file for upload (unqualified folder): $path") + } else if (!SyncedFolderUtils.isFileNameQualifiedForAutoUpload(file.name)) { + Log_OC.w(TAG, "Ignoring file for upload (unqualified file): $path") + } else { + Log_OC.d(TAG, "Adding path to upload: $path") + + if (it.id != null) { + filtered.add(path to it.id) + } else { + Log_OC.w(TAG, "cant adding path to upload, id is null") + } + } + } + } + + return filtered + } + + @Suppress("TooGenericExceptionCaught") + suspend fun markFileAsUploaded(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, "Error marking file as uploaded: ${e.message}", e) + } + } +} 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 index 40b698b..76f4b99 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadHelper.kt @@ -9,10 +9,12 @@ 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 @@ -29,6 +31,7 @@ class FileDownloadHelper { companion object { private var instance: FileDownloadHelper? = null + private const val TAG = "FileDownloadHelper" fun instance(): FileDownloadHelper = instance ?: synchronized(this) { instance ?: FileDownloadHelper().also { instance = it } @@ -44,17 +47,11 @@ class FileDownloadHelper { return false } - val fileStorageManager = FileDataStorageManager(user, MainApp.getAppContext().contentResolver) - val topParentId = fileStorageManager.getTopParentId(file) - - val isJobScheduled = backgroundJobManager.isStartFileDownloadJobScheduled(user, file.fileId) - return isJobScheduled || - if (file.isFolder) { - FileDownloadWorker.isDownloadingFolder(file.fileId) || - backgroundJobManager.isStartFileDownloadJobScheduled(user, topParentId) - } else { - FileDownloadWorker.isDownloading(user.accountName, file.fileId) - } + return if (file.isFolder) { + FolderDownloadWorker.isDownloading(file.fileId) + } else { + FileDownloadWorker.isDownloading(user.accountName, file.fileId) + } } fun cancelPendingOrCurrentDownloads(user: User?, files: List?) { @@ -141,4 +138,14 @@ class FileDownloadHelper { 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/FileDownloadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt index 8a14c6a..e160e1d 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt @@ -24,7 +24,6 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.model.WorkerState import com.nextcloud.model.WorkerStateLiveData import com.nextcloud.utils.ForegroundServiceHelper -import com.nextcloud.utils.extensions.getParentIdsOfSubfiles import com.nextcloud.utils.extensions.getPercent import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager @@ -45,7 +44,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import java.util.AbstractList import java.util.Optional import java.util.Vector -import java.util.concurrent.ConcurrentHashMap import kotlin.random.Random @Suppress("LongParameterList", "TooManyFunctions") @@ -63,7 +61,6 @@ class FileDownloadWorker( private val TAG = FileDownloadWorker::class.java.simpleName private val pendingDownloads = IndexedForest() - private val pendingFolderDownloads: MutableSet = ConcurrentHashMap.newKeySet() fun cancelOperation(accountName: String, fileId: Long) { pendingDownloads.all.forEach { @@ -75,8 +72,6 @@ class FileDownloadWorker( it.value?.payload?.isMatching(accountName, fileId) == true } - fun isDownloadingFolder(id: Long): Boolean = pendingFolderDownloads.contains(id) - const val FILE_REMOTE_PATH = "FILE_REMOTE_PATH" const val ACCOUNT_NAME = "ACCOUNT_NAME" const val BEHAVIOUR = "BEHAVIOUR" @@ -170,10 +165,6 @@ class FileDownloadWorker( private fun getRequestDownloads(ocFile: OCFile): AbstractList { val files = getFiles(ocFile) - val filesPaths = files.map { it.remotePath } - val parentIdsOfSubFiles = fileDataStorageManager?.getParentIdsOfSubfiles(filesPaths) ?: listOf() - pendingFolderDownloads.addAll(parentIdsOfSubFiles) - val downloadType = getDownloadType() conflictUploadId = inputData.keyValueMap[CONFLICT_UPLOAD_ID] as Long? 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 0000000..1ef2456 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorker.kt @@ -0,0 +1,167 @@ +/* + * 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.work.CoroutineWorker +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") +class FolderDownloadWorker( + private val accountManager: UserAccountManager, + private val context: Context, + private val viewThemeUtils: ViewThemeUtils, + 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 var notificationManager: FolderDownloadWorkerNotificationManager? = null + private lateinit var storageManager: FileDataStorageManager + + @Suppress("TooGenericExceptionCaught", "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() + } + + notificationManager = FolderDownloadWorkerNotificationManager(context, viewThemeUtils) + + Log_OC.d(TAG, "🕒 started for ${user.accountName} downloading ${folder.fileName}") + + val foregroundInfo = notificationManager?.getForegroundInfo(folder) ?: return Result.failure() + setForeground(foregroundInfo) + + 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) { + notificationManager?.showProgressNotification( + folder.fileName, + file.fileName, + index, + files.size + ) + } + + 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?.showCompletionMessage(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 { + pendingDownloads.remove(folder.fileId) + notificationManager?.dismiss() + } + } + } + + 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 suspend 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 0000000..6b2a1f4 --- /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.nextcloud.utils.ForegroundServiceHelper +import com.owncloud.android.R +import com.owncloud.android.datamodel.ForegroundServiceType +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.delay +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 const val DELAY = 1000L + } + + private fun getNotification(title: String, description: String? = null, progress: Int? = null): Notification = + notificationBuilder.apply { + setSmallIcon(R.drawable.ic_sync) + setContentTitle(title) + clearActions() + + description?.let { + setContentText(description) + } + + progress?.let { + setProgress(MAX_PROGRESS, progress, false) + addAction( + android.R.drawable.ic_menu_close_clear_cancel, + context.getString(R.string.common_cancel), + getCancelPendingIntent() + ) + } + + 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 + ) + } + + fun showProgressNotification(folderName: String, filename: String, currentIndex: Int, totalFileSize: Int) { + val currentFileIndex = (currentIndex + 1) + val description = context.getString(R.string.folder_download_counter, currentFileIndex, totalFileSize, filename) + val progress = (currentFileIndex * MAX_PROGRESS) / totalFileSize + val notification = getNotification(title = folderName, description = description, progress = progress) + notificationManager.notify(NOTIFICATION_ID, notification) + } + + suspend fun showCompletionMessage(folderName: String, success: Boolean) { + val title = if (success) { + context.getString(R.string.folder_download_success_notification_title, folderName) + } else { + context.getString(R.string.folder_download_error_notification_title, folderName) + } + + val notification = getNotification(title = title) + notificationManager.notify(NOTIFICATION_ID, notification) + + delay(DELAY) + dismiss() + } + + fun getForegroundInfo(folder: OCFile): ForegroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( + NOTIFICATION_ID, + getNotification(folder.fileName, progress = 0), + ForegroundServiceType.DataSync + ) + + suspend fun showNotAvailableDiskSpace() { + val title = context.getString(R.string.folder_download_insufficient_disk_space_notification_title) + val notification = getNotification(title) + notificationManager.notify(NOTIFICATION_ID, notification) + + delay(DELAY) + dismiss() + } + + 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 0000000..399c1cc --- /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/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 03110f0..4b3568a 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -7,11 +7,14 @@ */ 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.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 @@ -20,6 +23,7 @@ import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService import com.nextcloud.utils.extensions.getUploadIds 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 @@ -35,13 +39,12 @@ import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.FileUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.io.File -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ExecutionException import java.util.concurrent.Semaphore import javax.inject.Inject @@ -85,18 +88,42 @@ class FileUploadHelper { 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 - ) { - if (retryFailedUploadsSemaphore.tryAcquire()) { - try { - val failedUploads = uploadsStorageManager.failedUploads - if (failedUploads == null || failedUploads.isEmpty()) { - Log_OC.d(TAG, "Failed uploads are empty or null") - return + ): Boolean { + if (!retryFailedUploadsSemaphore.tryAcquire()) { + Log_OC.d(TAG, "skipping retryFailedUploads, already running") + return true + } + + var isUploadStarted = false + + try { + getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED) { + if (it.isNotEmpty()) { + isUploadStarted = true } retryUploads( @@ -104,14 +131,14 @@ class FileUploadHelper { connectivityService, accountManager, powerManagementService, - failedUploads + uploads = it ) - } finally { - retryFailedUploadsSemaphore.release() } - } else { - Log_OC.d(TAG, "Skip retryFailedUploads since it is already running") + } finally { + retryFailedUploadsSemaphore.release() } + + return isUploadStarted } fun retryCancelledUploads( @@ -120,18 +147,18 @@ class FileUploadHelper { accountManager: UserAccountManager, powerManagementService: PowerManagementService ): Boolean { - val cancelledUploads = uploadsStorageManager.cancelledUploadsForCurrentAccount - if (cancelledUploads == null || cancelledUploads.isEmpty()) { - return false + var result = false + getUploadsByStatus(accountManager.user.accountName, UploadStatus.UPLOAD_CANCELLED) { + result = retryUploads( + uploadsStorageManager, + connectivityService, + accountManager, + powerManagementService, + it + ) } - return retryUploads( - uploadsStorageManager, - connectivityService, - accountManager, - powerManagementService, - cancelledUploads - ) + return result } @Suppress("ComplexCondition") @@ -140,35 +167,32 @@ class FileUploadHelper { connectivityService: ConnectivityService, accountManager: UserAccountManager, powerManagementService: PowerManagementService, - failedUploads: Array + uploads: Array ): Boolean { var showNotExistMessage = false val isOnline = checkConnectivity(connectivityService) val connectivity = connectivityService.connectivity val batteryStatus = powerManagementService.battery - val accountNames = accountManager.accounts.filter { account -> - accountManager.getUser(account.name).isPresent - }.map { account -> - account.name - }.toHashSet() - for (failedUpload in failedUploads) { - if (!accountNames.contains(failedUpload.accountName)) { - uploadsStorageManager.removeUpload(failedUpload) - continue - } + val uploadsToRetry = mutableListOf() - val uploadResult = - checkUploadConditions(failedUpload, connectivity, batteryStatus, powerManagementService, isOnline) + for (upload in uploads) { + val uploadResult = checkUploadConditions( + upload, + connectivity, + batteryStatus, + powerManagementService, + isOnline + ) if (uploadResult != UploadResult.UPLOADED) { - if (failedUpload.lastResult != uploadResult) { + 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 - failedUpload.uploadStatus = UploadStatus.UPLOAD_FAILED + upload.uploadStatus = UploadStatus.UPLOAD_FAILED - failedUpload.lastResult = uploadResult - uploadsStorageManager.updateUpload(failedUpload) + upload.lastResult = uploadResult + uploadsStorageManager.updateUpload(upload) } if (uploadResult == UploadResult.FILE_NOT_FOUND) { showNotExistMessage = true @@ -176,15 +200,18 @@ class FileUploadHelper { continue } - failedUpload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS - uploadsStorageManager.updateUpload(failedUpload) + // 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) } - accountNames.forEach { accountName -> - val user = accountManager.getUser(accountName) - if (user.isPresent) { - backgroundJobManager.startFilesUploadJob(user.get(), failedUploads.getUploadIds(), false) - } + if (uploadsToRetry.isNotEmpty()) { + backgroundJobManager.startFilesUploadJob( + accountManager.user, + uploadsToRetry.toLongArray(), + false + ) } return showNotExistMessage @@ -205,7 +232,7 @@ class FileUploadHelper { showSameFileAlreadyExistsNotification: Boolean = true ) { val uploads = localPaths.mapIndexed { index, localPath -> - OCUpload(localPath, remotePaths[index], user.accountName).apply { + val result = OCUpload(localPath, remotePaths[index], user.accountName).apply { this.nameCollisionPolicy = nameCollisionPolicy isUseWifiOnly = requiresWifi isWhileChargingOnly = requiresCharging @@ -214,47 +241,54 @@ class FileUploadHelper { isCreateRemoteFolder = createRemoteFolder localAction = localBehavior } + + val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity()) + result.uploadId = id + result } - uploadsStorageManager.storeUploads(uploads) backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification) } fun removeFileUpload(remotePath: String, accountName: String) { - try { - val user = accountManager.getUser(accountName).get() - - // need to update now table in mUploadsStorageManager, - // since the operation will not get to be run by FileUploader#uploadFile - uploadsStorageManager.removeUpload(accountName, remotePath) - val uploadIds = uploadsStorageManager.getCurrentUploadIds(user.accountName) - cancelAndRestartUploadJob(user, uploadIds) - } catch (e: NoSuchElementException) { - Log_OC.e(TAG, "Error cancelling current upload because user does not exist!: " + e.message) - } + uploadsStorageManager.uploadDao.deleteByAccountAndRemotePath(accountName, remotePath) } - fun cancelFileUpload(remotePath: String, accountName: String) { + fun updateUploadStatus(remotePath: String, accountName: String, status: UploadStatus) { ioScope.launch { - val upload = uploadsStorageManager.getUploadByRemotePath(remotePath) - if (upload != null) { - cancelFileUploads(listOf(upload), accountName) - } else { - Log_OC.e(TAG, "Error cancelling current upload because upload does not exist!") - } + uploadsStorageManager.uploadDao.updateStatus(remotePath, accountName, status.value) } } - fun cancelFileUploads(uploads: List, accountName: String) { - for (upload in uploads) { - upload.uploadStatus = UploadStatus.UPLOAD_CANCELLED - uploadsStorageManager.updateUpload(upload) - } - - try { - val user = accountManager.getUser(accountName).get() - cancelAndRestartUploadJob(user, uploads.getUploadIds()) - } catch (e: NoSuchElementException) { - Log_OC.e(TAG, "Error restarting upload job because user does not exist!: " + e.message) + /** + * 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. + * + * Once the uploads are fetched, the [onCompleted] callback is invoked with the resulting array. + * + * @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`). + * @param onCompleted A callback invoked with the resulting array of [OCUpload] objects. + */ + fun getUploadsByStatus( + accountName: String?, + status: UploadStatus, + nameCollisionPolicy: NameCollisionPolicy? = null, + onCompleted: (Array) -> Unit + ) { + ioScope.launch { + val dao = uploadsStorageManager.uploadDao + val result = if (accountName != null) { + dao.getUploadsByAccountNameAndStatus(accountName, status.value, nameCollisionPolicy?.serialize()) + } else { + dao.getUploadsByStatus(status.value, nameCollisionPolicy?.serialize()) + }.map { it.toOCUpload(null) }.toTypedArray() + onCompleted(result) } } @@ -266,26 +300,16 @@ class FileUploadHelper { } @Suppress("ReturnCount") - fun isUploading(user: User?, file: OCFile?): Boolean { - if (user == null || file == null || !backgroundJobManager.isStartFileUploadJobScheduled(user)) { + fun isUploading(remotePath: String?, accountName: String?): Boolean { + accountName ?: return false + if (!backgroundJobManager.isStartFileUploadJobScheduled(accountName)) { return false } - val uploadCompletableFuture = CompletableFuture.supplyAsync { - uploadsStorageManager.getUploadByRemotePath(file.remotePath) - } - return try { - val upload = uploadCompletableFuture.get() - if (upload != null) { - upload.uploadStatus == UploadStatus.UPLOAD_IN_PROGRESS - } else { - false - } - } catch (e: ExecutionException) { - false - } catch (e: InterruptedException) { - 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 { @@ -364,7 +388,7 @@ class FileUploadHelper { val uploads = existingFiles.map { file -> file?.let { - OCUpload(file, user).apply { + val result = OCUpload(file, user).apply { fileSize = file.fileLength this.nameCollisionPolicy = nameCollisionPolicy isCreateRemoteFolder = true @@ -373,9 +397,12 @@ class FileUploadHelper { isWhileChargingOnly = false uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS } + + val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity()) + result.uploadId = id + result } } - uploadsStorageManager.storeUploads(uploads) val uploadIds: LongArray = uploads.filterNotNull().map { it.uploadId }.toLongArray() backgroundJobManager.startFilesUploadJob(user, uploadIds, true) } @@ -459,6 +486,14 @@ class FileUploadHelper { return false } + fun showFileUploadLimitMessage(activity: Activity) { + val message = activity.resources.getQuantityString( + R.plurals.file_upload_limit_message, + MAX_FILE_COUNT + ) + DisplayUtils.showSnackMessage(activity, message) + } + class UploadNotificationActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val accountName = intent.getStringExtra(FileUploadWorker.EXTRA_ACCOUNT_NAME) @@ -474,7 +509,9 @@ class FileUploadHelper { return } - instance().cancelFileUpload(remotePath, accountName) + FileUploadWorker.cancelCurrentUpload(remotePath, accountName, onCompleted = { + instance().updateUploadStatus(remotePath, accountName, UploadStatus.UPLOAD_CANCELLED) + }) } } } 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 index 37dd12f..093a3a9 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -7,10 +7,12 @@ */ package com.nextcloud.client.jobs.upload +import android.app.Notification import android.app.PendingIntent import android.content.Context +import androidx.core.app.NotificationCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.work.Worker +import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager @@ -21,8 +23,12 @@ import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.model.WorkerState import com.nextcloud.model.WorkerStateLiveData +import com.nextcloud.utils.ForegroundServiceHelper import com.nextcloud.utils.extensions.getPercent +import com.nextcloud.utils.extensions.updateStatus +import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.ForegroundServiceType import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload @@ -34,8 +40,12 @@ 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.notifications.NotificationUtils import com.owncloud.android.utils.ErrorMessageAdapter import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext import java.io.File import kotlin.random.Random @@ -51,7 +61,7 @@ class FileUploadWorker( val preferences: AppPreferences, val context: Context, params: WorkerParameters -) : Worker(context, params), +) : CoroutineWorker(context, params), OnDatatransferProgressListener { companion object { @@ -91,19 +101,44 @@ class FileUploadWorker( fun getUploadStartMessage(): String = FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE + + fun cancelCurrentUpload(remotePath: String, accountName: String, onCompleted: () -> Unit) { + currentUploadFileOperation?.let { + if (it.remotePath == remotePath && it.user.accountName == accountName) { + it.cancel(ResultCode.USER_CANCELLED) + onCompleted() + } + } + } + + fun isUploading(remotePath: String?, accountName: String?): Boolean { + currentUploadFileOperation?.let { + return it.remotePath == remotePath && it.user.accountName == accountName + } + + return false + } } private var lastPercent = 0 - private val notificationManager = UploadNotificationManager(context, viewThemeUtils, Random.nextInt()) + private val notificationId = Random.nextInt() + private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId) private val intents = FileUploaderIntents(context) private val fileUploaderDelegate = FileUploaderDelegate() @Suppress("TooGenericExceptionCaught") - override fun doWork(): Result = try { + override suspend fun doWork(): Result = try { Log_OC.d(TAG, "FileUploadWorker started") - backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class)) + val workerName = BackgroundJobManagerImpl.formatClassTag(this::class) + backgroundJobManager.logStartOfWorker(workerName) + + val notificationTitle = notificationManager.currentOperationTitle + ?: context.getString(R.string.foreground_service_upload) + val notification = createNotification(notificationTitle) + updateForegroundInfo(notification) + val result = uploadFiles() - backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result) + backgroundJobManager.logEndOfWorker(workerName, result) notificationManager.dismissNotification() if (result == Result.success()) { setIdleWorkerState() @@ -111,17 +146,37 @@ class FileUploadWorker( result } catch (t: Throwable) { Log_OC.e(TAG, "Error caught at FileUploadWorker $t") + cleanup() Result.failure() } - override fun onStopped() { + 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() + + private fun cleanup() { Log_OC.e(TAG, "FileUploadWorker stopped") setIdleWorkerState() currentUploadFileOperation?.cancel(null) notificationManager.dismissNotification() - - super.onStopped() } private fun setWorkerState(user: User?) { @@ -133,36 +188,36 @@ class FileUploadWorker( } @Suppress("ReturnCount", "LongMethod") - private fun uploadFiles(): Result { + private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) { val accountName = inputData.getString(ACCOUNT) if (accountName == null) { Log_OC.e(TAG, "accountName is null") - return Result.failure() + return@withContext Result.failure() } val uploadIds = inputData.getLongArray(UPLOAD_IDS) if (uploadIds == null) { Log_OC.e(TAG, "uploadIds is null") - return Result.failure() + return@withContext Result.failure() } val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1) if (currentBatchIndex == -1) { Log_OC.e(TAG, "currentBatchIndex is -1, cancelling") - return Result.failure() + return@withContext Result.failure() } val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1) if (totalUploadSize == -1) { Log_OC.e(TAG, "totalUploadSize is -1, cancelling") - return Result.failure() + 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 Result.failure() + return@withContext Result.failure() } val user = optionalUser.get() @@ -172,21 +227,19 @@ class FileUploadWorker( val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) for ((index, upload) in uploads.withIndex()) { + ensureActive() + if (preferences.isGlobalUploadPaused) { Log_OC.d(TAG, "Upload is paused, skip uploading files!") notificationManager.notifyPaused( intents.notificationStartIntent(null) ) - return Result.success() + return@withContext Result.success() } if (canExitEarly()) { notificationManager.showConnectionErrorNotification() - return Result.failure() - } - - if (isStopped) { - continue + return@withContext Result.failure() } setWorkerState(user) @@ -203,12 +256,16 @@ class FileUploadWorker( totalUploadSize = totalUploadSize ) - val result = upload(operation, user, client) + val result = withContext(Dispatchers.IO) { + upload(operation, user, client) + } + val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) + uploadsStorageManager.updateStatus(entity, result.isSuccess) currentUploadFileOperation = null sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) } - return Result.success() + return@withContext Result.success() } private fun sendUploadFinishEvent( @@ -346,6 +403,10 @@ class FileUploadWorker( return } + if (uploadResult.code == ResultCode.USER_CANCELLED) { + return + } + notificationManager.run { val errorMessage = ErrorMessageAdapter.getErrorCauseMessage( uploadResult, 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 index c773758..46e4a8c 100644 --- a/app/src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/logger/ui/LogsViewModel.kt @@ -6,6 +6,7 @@ */ package com.nextcloud.client.logger.ui +import android.annotation.SuppressLint import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -17,6 +18,7 @@ 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, diff --git a/app/src/main/java/com/nextcloud/client/onboarding/WhatsNewActivity.kt b/app/src/main/java/com/nextcloud/client/onboarding/WhatsNewActivity.kt index a824c7c..dd51319 100644 --- a/app/src/main/java/com/nextcloud/client/onboarding/WhatsNewActivity.kt +++ b/app/src/main/java/com/nextcloud/client/onboarding/WhatsNewActivity.kt @@ -132,6 +132,7 @@ class WhatsNewActivity : object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { onFinish() + isEnabled = false onBackPressedDispatcher.onBackPressed() } } diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java index 610ab2c..cc64609 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java @@ -63,6 +63,12 @@ public interface AppPreferences { boolean isShowHiddenFilesEnabled(); void setShowHiddenFilesEnabled(boolean enabled); + + boolean isSortFoldersBeforeFiles(); + void setSortFoldersBeforeFiles(boolean enabled); + + boolean isSortFavoritesFirst(); + void setSortFavoritesFirst(boolean enabled); boolean isShowEcosystemApps(); void setShowEcosystemApps(boolean enabled); @@ -344,10 +350,6 @@ public interface AppPreferences { long getPhotoSearchTimestamp(); - boolean isPowerCheckDisabled(); - - void setPowerCheckDisabled(boolean value); - void increasePinWrongAttempts(); void resetPinWrongAttempts(); diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java index 8806b5a..f6ebc7d 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -70,6 +70,8 @@ public final class AppPreferencesImpl implements AppPreferences { 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"; @@ -88,7 +90,6 @@ public final class AppPreferencesImpl implements AppPreferences { 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__POWER_CHECK_DISABLED = "power_check_disabled"; private static final String PREF__PIN_BRUTE_FORCE_COUNT = "pin_brute_force_count"; private static final String PREF__UID_PID = "uid_pid"; @@ -229,6 +230,26 @@ public final class AppPreferencesImpl implements AppPreferences { 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); @@ -689,16 +710,6 @@ public final class AppPreferencesImpl implements AppPreferences { return preferenceName + "_" + folderIdString; } - @Override - public boolean isPowerCheckDisabled() { - return preferences.getBoolean(PREF__POWER_CHECK_DISABLED, false); - } - - @Override - public void setPowerCheckDisabled(boolean value) { - preferences.edit().putBoolean(PREF__POWER_CHECK_DISABLED, value).apply(); - } - public void increasePinWrongAttempts() { int count = preferences.getInt(PREF__PIN_BRUTE_FORCE_COUNT, 0); preferences.edit().putInt(PREF__PIN_BRUTE_FORCE_COUNT, count + 1).apply(); diff --git a/app/src/main/java/com/nextcloud/model/SearchResultEntryType.kt b/app/src/main/java/com/nextcloud/model/SearchResultEntryType.kt index 0b36814..b71e4ca 100644 --- a/app/src/main/java/com/nextcloud/model/SearchResultEntryType.kt +++ b/app/src/main/java/com/nextcloud/model/SearchResultEntryType.kt @@ -18,11 +18,11 @@ enum class SearchResultEntryType { Unknown; fun iconId(): Int = when (this) { + CalendarEvent -> R.drawable.file_calendar Folder -> R.drawable.folder Note -> R.drawable.ic_edit Contact -> R.drawable.file_vcard - CalendarEvent -> R.drawable.file_calendar Deck -> R.drawable.ic_deck - else -> R.drawable.ic_find_in_page + Unknown -> R.drawable.ic_find_in_page } } diff --git a/app/src/main/java/com/nextcloud/ui/ChooseAccountDialogFragment.kt b/app/src/main/java/com/nextcloud/ui/ChooseAccountDialogFragment.kt index 7e63a84..b45dcd0 100644 --- a/app/src/main/java/com/nextcloud/ui/ChooseAccountDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/ChooseAccountDialogFragment.kt @@ -84,6 +84,7 @@ class ChooseAccountDialogFragment : return builder.create() } + @Suppress("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) accountManager = (activity as BaseActivity).userAccountManager diff --git a/app/src/main/java/com/nextcloud/ui/SetStatusMessageBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/SetStatusMessageBottomSheet.kt index 1c4f1d1..0215388 100644 --- a/app/src/main/java/com/nextcloud/ui/SetStatusMessageBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/ui/SetStatusMessageBottomSheet.kt @@ -335,6 +335,7 @@ class SetStatusMessageBottomSheet(val user: User, val currentStatus: Status?) : } } + @SuppressLint("NotifyDataSetChanged") @VisibleForTesting fun setPredefinedStatus(predefinedStatus: ArrayList) { adapter.list = predefinedStatus 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 0000000..96dedbb --- /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 index 125f700..61cd04e 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt @@ -18,7 +18,9 @@ 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.repository.AssistantRepository +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.utils.extensions.getSerializableArgument import com.owncloud.android.R @@ -79,10 +81,14 @@ class ComposeActivity : DrawerActivity() { isChecked = true } + val dao = NextcloudDatabase.instance().assistantDao() + nextcloudClient?.let { client -> AssistantScreen( viewModel = AssistantViewModel( - repository = AssistantRepository(client, capabilities) + accountName = userAccountManager.user.accountName, + remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities), + localRepository = AssistantLocalRepositoryImpl(dao) ), activity = this, capability = capabilities diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt index f66521e..6e265a2 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt @@ -39,8 +39,8 @@ enum class FileAction( // Uploads and downloads DOWNLOAD_FILE(R.id.action_download_file, R.string.filedetails_download, R.drawable.ic_cloud_download), - SYNC_FILE(R.id.action_sync_file, R.string.filedetails_sync_file, R.drawable.ic_cloud_sync_on), - CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync, R.drawable.ic_cloud_sync_off), + 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), @@ -84,7 +84,7 @@ enum class FileAction( SEND_SHARE_FILE, SEND_FILE, OPEN_FILE_WITH, - SYNC_FILE, + DOWNLOAD_FOLDER, CANCEL_SYNC, SELECT_ALL, SELECT_NONE, diff --git a/app/src/main/java/com/nextcloud/utils/BitmapExtensions.kt b/app/src/main/java/com/nextcloud/utils/BitmapExtensions.kt index ef84ba6..545354c 100644 --- a/app/src/main/java/com/nextcloud/utils/BitmapExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/BitmapExtensions.kt @@ -8,7 +8,15 @@ 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) @@ -38,3 +46,115 @@ fun Bitmap.scaleUntil(targetKB: Int): Bitmap { 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/FileHelper.kt b/app/src/main/java/com/nextcloud/utils/FileHelper.kt new file mode 100644 index 0000000..e417f88 --- /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 index 9e41f4c..c46c314 100644 --- a/app/src/main/java/com/nextcloud/utils/ForegroundServiceHelper.kt +++ b/app/src/main/java/com/nextcloud/utils/ForegroundServiceHelper.kt @@ -9,6 +9,7 @@ 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 @@ -32,7 +33,7 @@ object ForegroundServiceHelper { service, id, notification, - foregroundServiceType.getId() + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC ) } catch (e: Exception) { Log.d(TAG, "Exception caught at ForegroundServiceHelper.startService: $e") diff --git a/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt b/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt index f0800cc..e2ad9e5 100644 --- a/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt +++ b/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt @@ -45,52 +45,62 @@ class ShortcutUtil @Inject constructor(private val mContext: Context) { user: User, syncedFolderProvider: SyncedFolderProvider ) { - if (ShortcutManagerCompat.isRequestPinShortcutSupported(mContext)) { - val intent = Intent(mContext, FileDisplayActivity::class.java) - intent.action = FileDisplayActivity.OPEN_FILE - intent.putExtra(FileActivity.EXTRA_FILE, file.remotePath) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - val shortcutId = "nextcloud_shortcut_" + file.remoteId - val icon: IconCompat - var thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( - ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId - ) - if (thumbnail != null) { - thumbnail = bitmapToAdaptiveBitmap(thumbnail) - icon = IconCompat.createWithAdaptiveBitmap(thumbnail) - } else if (file.isFolder) { + 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 = SyncedFolderProvider.isAutoUploadFolder(syncedFolderProvider, file, user) val isDarkModeActive = syncedFolderProvider.preferences.isDarkModeEnabled - val overlayIconId = file.getFileOverlayIconId(isAutoUploadFolder) val drawable = MimeTypeUtil.getFolderIcon(isDarkModeActive, overlayIconId, mContext, viewThemeUtils) - val bitmapIcon = drawable.toBitmap() - icon = IconCompat.createWithBitmap(bitmapIcon) - } else { - icon = IconCompat.createWithResource( - mContext, - MimeTypeUtil.getFileTypeIconId(file.mimeType, file.fileName) - ) + IconCompat.createWithBitmap(drawable.toBitmap()) } - val longLabel = mContext.getString(R.string.pin_shortcut_label, file.fileName) - val pinShortcutInfo = ShortcutInfoCompat.Builder(mContext, shortcutId) - .setShortLabel(file.fileName) - .setLongLabel(longLabel) - .setIcon(icon) - .setIntent(intent) - .build() - val pinnedShortcutCallbackIntent = - ShortcutManagerCompat.createShortcutResultIntent(mContext, pinShortcutInfo) - val successCallback = PendingIntent.getBroadcast( + + else -> IconCompat.createWithResource( mContext, - 0, - pinnedShortcutCallbackIntent, - FLAG_IMMUTABLE - ) - ShortcutManagerCompat.requestPinShortcut( - mContext, - pinShortcutInfo, - successCallback.intentSender + MimeTypeUtil.getFileTypeIconId(file.mimeType, file.fileName) ) } } diff --git a/app/src/main/java/com/nextcloud/utils/autoRename/AutoRename.kt b/app/src/main/java/com/nextcloud/utils/autoRename/AutoRename.kt index b7150cc..11dba9a 100644 --- a/app/src/main/java/com/nextcloud/utils/autoRename/AutoRename.kt +++ b/app/src/main/java/com/nextcloud/utils/autoRename/AutoRename.kt @@ -8,12 +8,12 @@ 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.NextcloudVersion import com.owncloud.android.lib.resources.status.OCCapability import org.apache.commons.io.FilenameUtils import java.util.regex.Pattern @@ -25,12 +25,12 @@ object AutoRename { @Suppress("NestedBlockDepth") @JvmOverloads fun rename(filename: String, capability: OCCapability, isFolderPath: Boolean? = null): String { - Log_OC.d(TAG, "Before - $filename") - - if (!capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30)) { + 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() diff --git a/app/src/main/java/com/nextcloud/utils/extensions/DrawerActivityExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/DrawerActivityExtensions.kt index d096603..899d9ed 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/DrawerActivityExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/DrawerActivityExtensions.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -10,22 +10,10 @@ package com.nextcloud.utils.extensions import android.content.Intent import com.owncloud.android.MainApp import com.owncloud.android.R -import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.activity.DrawerActivity import com.owncloud.android.ui.activity.FileDisplayActivity -@Suppress("ReturnCount") -fun DrawerActivity.handleBackButtonEvent(currentDir: OCFile): Boolean { - if (DrawerActivity.menuItemId == R.id.nav_all_files && currentDir.isRootDirectory) { - moveTaskToBack(true) - return true - } - - val isParentDirExists = (storageManager.getFileById(currentDir.parentId) != null) - if (isParentDirExists) { - return false - } - +fun DrawerActivity.navigateToAllFiles() { DrawerActivity.menuItemId = R.id.nav_all_files setNavigationViewItemChecked() @@ -38,6 +26,4 @@ fun DrawerActivity.handleBackButtonEvent(currentDir: OCFile): Boolean { }.run { startActivity(this) } - - return true } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/Extensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/Extensions.kt index 1194301..598bff9 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/Extensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/Extensions.kt @@ -8,6 +8,8 @@ */ 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 @@ -24,6 +26,12 @@ 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 diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt index aea6376..2fec3fc 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -10,8 +10,10 @@ package com.nextcloud.utils.extensions import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile -fun FileDataStorageManager.getParentIdsOfSubfiles(paths: List): List = - fileDao.getParentIdsOfSubfiles(paths) +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() diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt index 94ae83b..a55345d 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt @@ -11,6 +11,9 @@ 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) @@ -23,3 +26,27 @@ fun File?.logFileSize(tag: String) { 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.e(TAG, "given path is null or empty") + return null + } + + val file = File(this) + if (!file.exists()) { + Log_OC.e(TAG, "File does not exist: $this") + return null + } + + return file +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCCapabilityExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCCapabilityExtensions.kt index b57eda9..8628548 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCCapabilityExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCCapabilityExtensions.kt @@ -8,11 +8,31 @@ package com.nextcloud.utils.extensions import com.google.gson.Gson +import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.lib.resources.status.OCCapability import org.json.JSONException private val gson = Gson() +/** + * 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) @@ -33,7 +53,7 @@ private fun jsonToList(json: String?): List { return try { return gson.fromJson(json, Array::class.java).toList() - } catch (e: JSONException) { + } catch (_: JSONException) { emptyList() } } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt index fcdb974..5251aa3 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt @@ -7,10 +7,50 @@ 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.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?): Boolean { + 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 sent for upload - cannot determine creation time: ${file.absolutePath}") + return false + } + } + + // Skip files that haven't changed since last scan (already processed) + // BUT only if this is not the first scan + if (lastScanTimestampMs != -1L && lastModified < lastScanTimestampMs) { + Log_OC.d(TAG, "Skipping unchanged file (last modified < last scan): ${file.absolutePath}") + return true + } + + return false +} + fun List.filterEnabledOrWithoutEnabledParent(): List = filter { it.isEnabled || !hasEnabledParent(it.localPath) } @@ -25,3 +65,27 @@ fun List.hasEnabledParent(localPath: String?): Boolean { 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 + } +} 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 0000000..962c586 --- /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/UploadStorageManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UploadStorageManagerExtensions.kt new file mode 100644 index 0000000..e86a9f0 --- /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/UriExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UriExtensions.kt new file mode 100644 index 0000000..174cf97 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/UriExtensions.kt @@ -0,0 +1,43 @@ +/* + * 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.net.Uri +import android.provider.MediaStore +import com.owncloud.android.lib.common.utils.Log_OC + +/** + * Returns absolute filesystem path to the media item on disk. I/O errors that could occur. From Android 11 onwards, + * this column is read-only for apps that target R and higher. + * + * [More Info](https://developer.android.com/reference/android/provider/MediaStore.MediaColumns#DATA) + */ +@Suppress("ReturnCount", "TooGenericExceptionCaught") +fun Uri.toFilePath(context: Context): String? { + try { + val projection = arrayOf(MediaStore.MediaColumns.DATA) + + val resolver = context.contentResolver + + resolver.query(this, projection, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) { + return null + } + + val dataIdx = cursor.getColumnIndex(MediaStore.MediaColumns.DATA) + val data = if (dataIdx != -1) cursor.getString(dataIdx) else null + return data + } + + return null + } catch (e: Exception) { + Log_OC.e("UriExtensions", "exception, toFilePath: $e") + return null + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt index 29cca6d..36195f7 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt @@ -16,7 +16,8 @@ import android.view.View import android.view.ViewGroup import android.view.ViewOutlineProvider import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.behavior.HideBottomViewOnScrollBehavior +import com.nextcloud.ui.behavior.OnScrollBehavior +import com.owncloud.android.lib.common.utils.Log_OC fun View?.setVisibleIf(condition: Boolean) { if (this == null) return @@ -85,15 +86,20 @@ fun createRoundedOutline(context: Context, cornerRadiusValue: Float): ViewOutlin } } -@Suppress("UNCHECKED_CAST", "ReturnCount") +@Suppress("UNCHECKED_CAST", "ReturnCount", "TooGenericExceptionCaught") fun T.slideHideBottomBehavior(visible: Boolean) { this ?: return val params = layoutParams as? CoordinatorLayout.LayoutParams ?: return - val behavior = params.behavior as? HideBottomViewOnScrollBehavior ?: return - - if (visible) { - behavior.slideUp(this) - } else { - behavior.slideDown(this) + 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/fileNameValidator/FileNameValidator.kt b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt index c7c3884..dd1338e 100644 --- a/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt +++ b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt @@ -10,6 +10,7 @@ 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.forbiddenFilenameBaseNames import com.nextcloud.utils.extensions.forbiddenFilenameCharacters import com.nextcloud.utils.extensions.forbiddenFilenameExtensions @@ -17,7 +18,6 @@ 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.NextcloudVersion import com.owncloud.android.lib.resources.status.OCCapability object FileNameValidator { @@ -49,10 +49,11 @@ object FileNameValidator { } } - if (!capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30)) { + if (!capability.checkWCFRestrictions()) { return null } + // region WCF related checks checkInvalidCharacters(filename, capability, context)?.let { return it } val filenameVariants = setOf(filename.lowercase(), filename.removeFileExtension().lowercase()) @@ -91,6 +92,7 @@ object FileNameValidator { } } } + // endregion return null } diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java index 9358a3c..64c8f48 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.java +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -636,7 +636,7 @@ public class MainApp extends Application implements HasAndroidInjector, NetworkC } if (!preferences.isAutoUploadInitialized()) { - FilesSyncHelper.startFilesSyncForAllFolders(syncedFolderProvider, backgroundJobManager,false, new String[]{}); + FilesSyncHelper.startAutoUploadImmediately(syncedFolderProvider, backgroundJobManager, false); preferences.setAutoUploadInit(true); } @@ -706,6 +706,13 @@ public class MainApp extends Application implements HasAndroidInjector, NetworkC 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"); } diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java index 7a1648e..c87c015 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java @@ -48,7 +48,6 @@ import android.widget.Toast; import com.blikoon.qrcodescanner.QrCodeActivity; import com.google.android.material.button.MaterialButton; -import com.google.android.material.snackbar.Snackbar; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -105,7 +104,6 @@ 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.CapabilityUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; import java.io.InputStream; @@ -1104,13 +1102,6 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity // 4. we got the authentication method required by the server mServerInfo = (GetServerInfoOperation.ServerInfo) (result.getData().get(0)); - // show outdated warning - if (CapabilityUtils.checkOutdatedWarning(getResources(), - mServerInfo.mVersion, - mServerInfo.hasExtendedSupport)) { - DisplayUtils.showServerOutdatedSnackbar(this, Snackbar.LENGTH_INDEFINITE); - } - if (webViewUser != null && !webViewUser.isEmpty() && webViewPassword != null && !webViewPassword.isEmpty()) { checkBasicAuthorization(webViewUser, webViewPassword); diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index c64e054..517f402 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2411,6 +2411,7 @@ public class FileDataStorageManager { 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()); @@ -2419,6 +2420,8 @@ public class FileDataStorageManager { 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()); return contentValues; } @@ -2595,6 +2598,7 @@ public class FileDataStorageManager { 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)); @@ -2603,6 +2607,7 @@ public class FileDataStorageManager { 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)); } return capability; diff --git a/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java b/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java index 43aa27f..e1a2016 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java @@ -14,15 +14,11 @@ import android.net.Uri; import com.owncloud.android.db.ProviderMeta; import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.utils.SyncedFolderUtils; import java.io.BufferedInputStream; -import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; -import java.util.HashSet; -import java.util.Set; import java.util.zip.CRC32; /** @@ -51,21 +47,6 @@ public class FilesystemDataProvider { new String[]{syncedFolderId}); } - public void updateFilesystemFileAsSentForUpload(String path, String syncedFolderId) { - Log_OC.d(TAG, "updateFilesystemFileAsSentForUpload called, path: " + path + " ID: " + syncedFolderId); - - ContentValues cv = new ContentValues(); - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD, 1); - - contentResolver.update( - ProviderMeta.ProviderTableMeta.CONTENT_URI_FILESYSTEM, - cv, - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH + " = ? and " + - ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID + " = ?", - new String[]{path, syncedFolderId} - ); - } - public void storeOrUpdateFileValue(String localPath, long modifiedAt, boolean isFolder, SyncedFolder syncedFolder) { Log_OC.d(TAG, "storeOrUpdateFileValue called, localPath: " + localPath + " ID: " + syncedFolder.getId()); @@ -124,56 +105,6 @@ public class FilesystemDataProvider { } } - public Set getFilesForUpload(String localPath, String syncedFolderId) { - Log_OC.d(TAG, "getFilesForUpload called, localPath: " + localPath + " ID: " + syncedFolderId); - - Set localPathsToUpload = new HashSet<>(); - - String likeParam = localPath + "%"; - - Cursor cursor = contentResolver.query( - ProviderMeta.ProviderTableMeta.CONTENT_URI_FILESYSTEM, - null, - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH + " LIKE ? and " + - ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID + " = ? and " + - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD + " = ? and " + - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER + " = ?", - new String[]{likeParam, syncedFolderId, "0", "0"}, - null); - - if (cursor != null) { - if (cursor.moveToFirst()) { - do { - String value = cursor.getString(cursor.getColumnIndexOrThrow( - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH)); - if (value == null) { - Log_OC.e(TAG, "Cannot get local path"); - } else { - File file = new File(value); - if (!file.exists()) { - Log_OC.w(TAG, "Ignoring file for upload (doesn't exist): " + value); - } else if (!SyncedFolderUtils.isQualifiedFolder(file.getParent())) { - Log_OC.w(TAG, "Ignoring file for upload (unqualified folder): " + value); - } else if (!SyncedFolderUtils.isFileNameQualifiedForAutoUpload(file.getName())) { - Log_OC.w(TAG, "Ignoring file for upload (unqualified file): " + value); - } else { - Log_OC.d(TAG, "adding path to the localPathsToUpload: " + value); - localPathsToUpload.add(value); - } - } - } while (cursor.moveToNext()); - } else { - Log_OC.w(TAG, "cursor cannot move"); - } - - cursor.close(); - } else { - Log_OC.e(TAG, "getFilesForUpload called, cursor is null"); - } - - return localPathsToUpload; - } - private FileSystemDataSet getFilesystemDataSet(String localPathParam, SyncedFolder syncedFolder) { Log_OC.d(TAG, "getFilesForUpload called, localPath: " + localPathParam + " ID: " + syncedFolder.getId()); diff --git a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java index ee2cdda..257edc8 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java +++ b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java @@ -10,7 +10,10 @@ 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; @@ -105,7 +108,7 @@ public class SyncedFolder implements Serializable, Cloneable { * * @param id id */ - protected SyncedFolder(long id, + public SyncedFolder(long id, String localPath, String remotePath, boolean wifiOnly, @@ -176,6 +179,28 @@ public class SyncedFolder implements Serializable, Cloneable { 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; } @@ -276,15 +301,20 @@ public class SyncedFolder implements Serializable, Cloneable { this.excludeHidden = excludeHidden; } - public boolean containsTypedFile(String filePath){ + public boolean containsTypedFile(File file,String filePath){ boolean isCorrectMediaType = - (getType() == MediaFolderType.IMAGE && MimeTypeUtil.isImage(new File(filePath))) || - (getType() == MediaFolderType.VIDEO && MimeTypeUtil.isVideo(new File(filePath))) || - getType() == MediaFolderType.CUSTOM; + (getType() == MediaFolderType.IMAGE && MimeTypeUtil.isImage(file)) || + (getType() == MediaFolderType.VIDEO && MimeTypeUtil.isVideo(file)) || + getType() == MediaFolderType.CUSTOM; return filePath.contains(localPath) && 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/SyncedFolderProvider.java b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.java index fbcba50..ff4c00c 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.java +++ b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.java @@ -15,9 +15,14 @@ import android.net.Uri; 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.SyncedFolderEntity; +import com.nextcloud.client.database.entity.SyncedFolderEntityKt; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.client.preferences.AppPreferencesImpl; import com.nextcloud.client.preferences.SubFolderRule; +import com.owncloud.android.MainApp; import com.owncloud.android.db.ProviderMeta; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.resources.files.model.ServerFileInterface; @@ -42,6 +47,7 @@ public class SyncedFolderProvider extends Observable { private final ContentResolver mContentResolver; private final AppPreferences preferences; private final Clock clock; + public final SyncedFolderDao dao = NextcloudDatabase.getInstance(MainApp.getAppContext()).syncedFolderDao(); /** * constructor. @@ -183,33 +189,11 @@ public class SyncedFolderProvider extends Observable { } public SyncedFolder findByLocalPathAndAccount(String localPath, User user) { - SyncedFolder result = null; - Cursor cursor = mContentResolver.query( - ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, - null, - ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_LOCAL_PATH + " LIKE ? AND " + - ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ACCOUNT + " =? ", - new String[]{localPath + "%", user.getAccountName()}, - null - ); - - if (cursor != null && cursor.getCount() == 1) { - result = createSyncedFolderFromCursor(cursor); - } else { - if (cursor == null) { - Log_OC.e(TAG, "Sync folder db cursor for local path=" + localPath + " in NULL."); - } else { - Log_OC.e(TAG, cursor.getCount() + " items for local path=" + localPath - + " available in sync folder db. Expected 1. Failed to update sync folder db."); - } + final SyncedFolderEntity entity = dao.findByLocalPathAndAccount(localPath, user.getAccountName()); + if (entity == null) { + return null; } - - if (cursor != null) { - cursor.close(); - } - - return result; - + return SyncedFolderEntityKt.toSyncedFolder(entity); } @Nullable diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index f717603..4cea79a 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -11,6 +11,7 @@ */ package com.owncloud.android.datamodel; +import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; @@ -77,6 +78,8 @@ 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. */ @@ -194,7 +197,8 @@ public final class ThumbnailsCacheManager { Bitmap thumbnail = ThumbnailUtils.extractThumbnail(bitmap, pxW, pxH); // Rotate image, obeying exif tag - thumbnail = BitmapUtils.rotateImage(thumbnail,path); + int orientation = getExifOrientation(path); + thumbnail = BitmapExtensionsKt.rotateBitmapViaExif(thumbnail, orientation); // Add thumbnail to cache // do not overwrite any pre-existing image @@ -881,7 +885,7 @@ public final class ThumbnailsCacheManager { private final WeakReference mImageViewReference; private File mFile; private String mImageKey; - private final Context mContext; + @SuppressLint("StaticFieldLeak") private final Context mContext; private final ViewThemeUtils viewThemeUtils; public MediaThumbnailGenerationTask(ImageView imageView, @@ -1006,7 +1010,7 @@ public final class ThumbnailsCacheManager { private final String mUserId; private final String displayName; private final String mServerName; - private final Context mContext; + @SuppressLint("StaticFieldLeak") private final Context mContext; public AvatarGenerationTask(AvatarGenerationListener avatarGenerationListener, diff --git a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.java index d7b2982..9a72022 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.java @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2024 Jonas Mayer * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky @@ -14,14 +15,9 @@ */ package com.owncloud.android.datamodel; -import android.content.ContentProviderOperation; -import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.ContentValues; -import android.content.OperationApplicationException; import android.database.Cursor; -import android.net.Uri; -import android.os.RemoteException; import com.nextcloud.client.account.CurrentAccountProvider; import com.nextcloud.client.account.User; @@ -94,97 +90,6 @@ public class UploadsStorageManager extends Observable { } } - /** - * Stores an upload object in DB. - * - * @param ocUpload Upload object to store - * @return upload id, -1 if the insert process fails. - */ - public long storeUpload(OCUpload ocUpload) { - OCUpload existingUpload = getPendingCurrentOrFailedUpload(ocUpload); - if (existingUpload != null) { - Log_OC.v(TAG, "Will update upload in db since " + ocUpload.getLocalPath() + " already exists as " + - "pending, current or failed upload"); - long existingId = existingUpload.getUploadId(); - ocUpload.setUploadId(existingId); - updateUpload(ocUpload); - return existingId; - } - - - Log_OC.v(TAG, "Inserting " + ocUpload.getLocalPath() + " with status=" + ocUpload.getUploadStatus()); - - ContentValues cv = getContentValues(ocUpload); - Uri result = getDB().insert(ProviderTableMeta.CONTENT_URI_UPLOADS, cv); - - Log_OC.d(TAG, "storeUpload returns with: " + result + " for file: " + ocUpload.getLocalPath()); - if (result == null) { - Log_OC.e(TAG, "Failed to insert item " + ocUpload.getLocalPath() + " into upload db."); - return -1; - } else { - long new_id = Long.parseLong(result.getPathSegments().get(1)); - ocUpload.setUploadId(new_id); - notifyObserversNow(); - - return new_id; - } - - } - - public void storeUploads(final List ocUploads) { - Log_OC.v(TAG, "Inserting " + ocUploads.size() + " uploads"); - ArrayList operations = new ArrayList<>(ocUploads.size()); - for (OCUpload ocUpload : ocUploads) { - - OCUpload existingUpload = getPendingCurrentOrFailedUpload(ocUpload); - if (existingUpload != null) { - Log_OC.v(TAG, "Will update upload in db since " + ocUpload.getLocalPath() + " already exists as" + - " pending, current or failed upload"); - ocUpload.setUploadId(existingUpload.getUploadId()); - updateUpload(ocUpload); - continue; - } - - final ContentProviderOperation operation = ContentProviderOperation - .newInsert(ProviderTableMeta.CONTENT_URI_UPLOADS) - .withValues(getContentValues(ocUpload)) - .build(); - operations.add(operation); - } - - try { - final ContentProviderResult[] contentProviderResults = getDB().applyBatch(MainApp.getAuthority(), operations); - for (int i = 0; i < contentProviderResults.length; i++) { - final ContentProviderResult result = contentProviderResults[i]; - final long new_id = Long.parseLong(result.uri.getPathSegments().get(1)); - ocUploads.get(i).setUploadId(new_id); - } - notifyObserversNow(); - } catch (OperationApplicationException | RemoteException e) { - Log_OC.e(TAG, "Error inserting uploads", e); - } - } - - @NonNull - private ContentValues getContentValues(OCUpload ocUpload) { - ContentValues cv = new ContentValues(); - cv.put(ProviderTableMeta.UPLOADS_LOCAL_PATH, ocUpload.getLocalPath()); - cv.put(ProviderTableMeta.UPLOADS_REMOTE_PATH, ocUpload.getRemotePath()); - cv.put(ProviderTableMeta.UPLOADS_ACCOUNT_NAME, ocUpload.getAccountName()); - cv.put(ProviderTableMeta.UPLOADS_FILE_SIZE, ocUpload.getFileSize()); - cv.put(ProviderTableMeta.UPLOADS_STATUS, ocUpload.getUploadStatus().value); - cv.put(ProviderTableMeta.UPLOADS_LOCAL_BEHAVIOUR, ocUpload.getLocalAction()); - cv.put(ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY, ocUpload.getNameCollisionPolicy().serialize()); - cv.put(ProviderTableMeta.UPLOADS_IS_CREATE_REMOTE_FOLDER, ocUpload.isCreateRemoteFolder() ? 1 : 0); - cv.put(ProviderTableMeta.UPLOADS_LAST_RESULT, ocUpload.getLastResult().getValue()); - cv.put(ProviderTableMeta.UPLOADS_CREATED_BY, ocUpload.getCreatedBy()); - cv.put(ProviderTableMeta.UPLOADS_IS_WHILE_CHARGING_ONLY, ocUpload.isWhileChargingOnly() ? 1 : 0); - cv.put(ProviderTableMeta.UPLOADS_IS_WIFI_ONLY, ocUpload.isUseWifiOnly() ? 1 : 0); - cv.put(ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN, ocUpload.getFolderUnlockToken()); - return cv; - } - - /** * Update an upload object in DB. * @@ -387,52 +292,6 @@ public class UploadsStorageManager extends Observable { return getUploads(null, (String[]) null); } - public OCUpload getPendingCurrentOrFailedUpload(OCUpload upload) { - try (Cursor cursor = getDB().query( - ProviderTableMeta.CONTENT_URI_UPLOADS, - null, - ProviderTableMeta.UPLOADS_REMOTE_PATH + "=? and " + - ProviderTableMeta.UPLOADS_LOCAL_PATH + "=? and " + - ProviderTableMeta.UPLOADS_ACCOUNT_NAME + "=? and (" + - ProviderTableMeta.UPLOADS_STATUS + "=? or " + - ProviderTableMeta.UPLOADS_STATUS + "=? )", - new String[]{ - upload.getRemotePath(), - upload.getLocalPath(), - upload.getAccountName(), - String.valueOf(UploadStatus.UPLOAD_IN_PROGRESS.value), - String.valueOf(UploadStatus.UPLOAD_FAILED.value) - }, - ProviderTableMeta.UPLOADS_REMOTE_PATH + " ASC")) { - - if (cursor != null) { - if (cursor.moveToFirst()) { - return createOCUploadFromCursor(cursor); - } - } - } - return null; - } - - public OCUpload getUploadByRemotePath(String remotePath) { - OCUpload result = null; - try (Cursor cursor = getDB().query( - ProviderTableMeta.CONTENT_URI_UPLOADS, - null, - ProviderTableMeta.UPLOADS_REMOTE_PATH + "=?", - new String[]{remotePath}, - ProviderTableMeta.UPLOADS_REMOTE_PATH + " ASC")) { - - if (cursor != null) { - if (cursor.moveToFirst()) { - result = createOCUploadFromCursor(cursor); - } - } - } - Log_OC.d(TAG, "Retrieve job " + result + " for remote path " + remotePath); - return result; - } - public @Nullable OCUpload getUploadById(long id) { OCUpload result = null; @@ -508,19 +367,6 @@ public class UploadsStorageManager extends Observable { return getUploadPage(QUERY_PAGE_SIZE, afterId, true, selection, selectionArgs); } - private String getInProgressAndDelayedUploadsSelection() { - return "( " + ProviderTableMeta.UPLOADS_STATUS + EQUAL + UploadStatus.UPLOAD_IN_PROGRESS.value + - OR + ProviderTableMeta.UPLOADS_LAST_RESULT + - EQUAL + UploadResult.DELAYED_FOR_WIFI.getValue() + - OR + ProviderTableMeta.UPLOADS_LAST_RESULT + - EQUAL + UploadResult.LOCK_FAILED.getValue() + - OR + ProviderTableMeta.UPLOADS_LAST_RESULT + - EQUAL + UploadResult.DELAYED_FOR_CHARGING.getValue() + - OR + ProviderTableMeta.UPLOADS_LAST_RESULT + - EQUAL + UploadResult.DELAYED_IN_POWER_SAVE_MODE.getValue() + - " ) AND " + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL; - } - @NonNull private List getUploadPage(long limit, final long afterId, final boolean descending, @Nullable String selection, @Nullable String... selectionArgs) { List uploads = new ArrayList<>(); @@ -630,17 +476,6 @@ public class UploadsStorageManager extends Observable { return upload; } - public OCUpload[] getCurrentAndPendingUploadsForCurrentAccount() { - User user = currentAccountProvider.getUser(); - - return getCurrentAndPendingUploadsForAccount(user.getAccountName()); - } - - public OCUpload[] getCurrentAndPendingUploadsForAccount(final @NonNull String accountName) { - String inProgressUploadsSelection = getInProgressAndDelayedUploadsSelection(); - return getUploads(inProgressUploadsSelection, accountName); - } - public long[] getCurrentUploadIds(final @NonNull String accountName) { final var result = uploadDao.getAllIds(UploadStatus.UPLOAD_IN_PROGRESS.value, accountName); return result.stream() @@ -648,58 +483,10 @@ public class UploadsStorageManager extends Observable { .toArray(); } - /** - * Get all failed uploads. - */ - public OCUpload[] getFailedUploads() { - return getUploads("(" + ProviderTableMeta.UPLOADS_STATUS + IS_EQUAL + - OR + ProviderTableMeta.UPLOADS_LAST_RESULT + - EQUAL + UploadResult.DELAYED_FOR_WIFI.getValue() + - OR + ProviderTableMeta.UPLOADS_LAST_RESULT + - EQUAL + UploadResult.LOCK_FAILED.getValue() + - OR + ProviderTableMeta.UPLOADS_LAST_RESULT + - EQUAL + UploadResult.DELAYED_FOR_CHARGING.getValue() + - OR + ProviderTableMeta.UPLOADS_LAST_RESULT + - EQUAL + UploadResult.DELAYED_IN_POWER_SAVE_MODE.getValue() + - " ) AND " + ProviderTableMeta.UPLOADS_LAST_RESULT + - "!= " + UploadResult.VIRUS_DETECTED.getValue() - , String.valueOf(UploadStatus.UPLOAD_FAILED.value)); - } - public OCUpload[] getUploadsForAccount(final @NonNull String accountName) { return getUploads(ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL, accountName); } - public OCUpload[] getFinishedUploadsForCurrentAccount() { - User user = currentAccountProvider.getUser(); - - return getUploads(ProviderTableMeta.UPLOADS_STATUS + EQUAL + UploadStatus.UPLOAD_SUCCEEDED.value + AND + - ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL, user.getAccountName()); - } - - public OCUpload[] getCancelledUploadsForCurrentAccount() { - User user = currentAccountProvider.getUser(); - - return getUploads(ProviderTableMeta.UPLOADS_STATUS + EQUAL + UploadStatus.UPLOAD_CANCELLED.value + AND + - ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL, user.getAccountName()); - } - - public OCUpload[] getFailedButNotDelayedUploadsForCurrentAccount() { - User user = currentAccountProvider.getUser(); - - return getUploads(ProviderTableMeta.UPLOADS_STATUS + EQUAL + UploadStatus.UPLOAD_FAILED.value + - AND + ProviderTableMeta.UPLOADS_LAST_RESULT + - ANGLE_BRACKETS + UploadResult.DELAYED_FOR_WIFI.getValue() + - AND + ProviderTableMeta.UPLOADS_LAST_RESULT + - ANGLE_BRACKETS + UploadResult.LOCK_FAILED.getValue() + - AND + ProviderTableMeta.UPLOADS_LAST_RESULT + - ANGLE_BRACKETS + UploadResult.DELAYED_FOR_CHARGING.getValue() + - AND + ProviderTableMeta.UPLOADS_LAST_RESULT + - ANGLE_BRACKETS + UploadResult.DELAYED_IN_POWER_SAVE_MODE.getValue() + - AND + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + IS_EQUAL, - user.getAccountName()); - } - private ContentResolver getDB() { return contentResolver; } @@ -797,7 +584,7 @@ public class UploadsStorageManager extends Observable { upload.getRemotePath(), localPath ); - } else { + } else if (uploadResult.getCode() != RemoteOperationResult.ResultCode.USER_CANCELLED){ updateUploadStatus( upload.getOCUploadId(), UploadStatus.UPLOAD_FAILED, diff --git a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java index 196f42c..46905d6 100644 --- a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java +++ b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java @@ -23,7 +23,7 @@ import java.util.List; */ public class ProviderMeta { public static final String DB_NAME = "filelist"; - public static final int DB_VERSION = 93; + public static final int DB_VERSION = 96; private ProviderMeta() { // No instance @@ -52,6 +52,7 @@ public class ProviderMeta { 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"; @@ -285,10 +286,12 @@ public class ProviderMeta { 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"; //Columns of Uploads table public static final String UPLOADS_LOCAL_PATH = "local_path"; diff --git a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java index 3b9199e..6665598 100644 --- a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java +++ b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java @@ -434,7 +434,7 @@ public class FileMenuFilter { private boolean anyFileUploading() { for (OCFile file : files) { - if (FileUploadHelper.Companion.instance().isUploading(user, file)) { + if (FileUploadHelper.Companion.instance().isUploading(file.getRemotePath(), user.getAccountName())) { return true; } } 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 index 41ab166..071a1c0 100644 --- a/app/src/main/java/com/owncloud/android/files/services/NameCollisionPolicy.java +++ b/app/src/main/java/com/owncloud/android/files/services/NameCollisionPolicy.java @@ -7,12 +7,24 @@ package com.owncloud.android.files.services; /** - * Ordinal of enumerated constants is important for old data compatibility. + * 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, // Ordinal corresponds to old forceOverwrite = false (0 in database) - OVERWRITE, // Ordinal corresponds to old forceOverwrite = true (1 in database) - CANCEL, + RENAME, + OVERWRITE, + SKIP, ASK_USER; public static final NameCollisionPolicy DEFAULT = RENAME; diff --git a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java index 928f437..d7b50d7 100644 --- a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java @@ -38,7 +38,9 @@ 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.Iterator; import java.util.Set; @@ -115,7 +117,11 @@ public class DownloadFileOperation extends RemoteOperation { if (file.getStoragePath() != null) { File parentFile = new File(file.getStoragePath()).getParentFile(); if (parentFile != null && !parentFile.exists()) { - parentFile.mkdirs(); + 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()) { diff --git a/app/src/main/java/com/owncloud/android/operations/RenameFileOperation.java b/app/src/main/java/com/owncloud/android/operations/RenameFileOperation.java index 3f1c3a4..93e60b1 100644 --- a/app/src/main/java/com/owncloud/android/operations/RenameFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RenameFileOperation.java @@ -25,6 +25,9 @@ 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. @@ -161,26 +164,32 @@ public class RenameFileOperation extends SyncOperation { } // create a test file String tmpFolderName = FileStorageUtils.getTemporalPath(""); - File testFile = new File(tmpFolderName + newName); - File tmpFolder = testFile.getParentFile(); - if (!tmpFolder.exists() && !tmpFolder.mkdirs()) { - Log_OC.e(TAG, "Unable to create parent folder " + tmpFolder.getAbsolutePath()); + 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.isDirectory()) { + if (tmpFolder != null && !Files.isDirectory(tmpFolder)) { throw new IOException("Unexpected error: temporal directory could not be created"); } try { - testFile.createNewFile(); // return value is ignored; it could be 'false' because - // the file already existed, that doesn't invalidate the name - } catch (IOException e) { + 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 = testFile.exists() && testFile.isFile(); + boolean result = Files.exists(testFile) && Files.isRegularFile(testFile); - // cleaning ; result is ignored, since there is not much we could do in case of failure, - // but repeat and repeat... - testFile.delete(); + try { + Files.deleteIfExists(testFile); + } catch (Exception e) { + Log_OC.e("Error deleting file: ", e.getMessage()); + return true; + } return result; } diff --git a/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java index 20a75f5..2b58296 100644 --- a/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java @@ -28,7 +28,6 @@ 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.operations.common.SyncOperation; import com.owncloud.android.services.OperationsService; import com.owncloud.android.utils.FileStorageUtils; @@ -485,7 +484,7 @@ public class SynchronizeFolderOperation extends SyncOperation { Log_OC.d(TAG, "Exception caught at startDirectDownloads" + e); } } else { - mFilesForDirectDownload.forEach(file -> fileDownloadHelper.downloadFile(user, file)); + fileDownloadHelper.downloadFolder(mLocalFolder, user.getAccountName()); } } diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index 29db48f..ddeccdc 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -70,8 +70,6 @@ import com.owncloud.android.utils.theme.CapabilityUtils; import org.apache.commons.httpclient.HttpStatus; import org.apache.commons.httpclient.methods.RequestEntity; -import org.lukhnos.nnio.file.Files; -import org.lukhnos.nnio.file.Paths; import java.io.File; import java.io.FileInputStream; @@ -84,6 +82,8 @@ 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.Paths; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -244,7 +244,7 @@ public class UploadFileOperation extends SyncOperation { "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; @@ -1183,11 +1183,13 @@ public class UploadFileOperation extends SyncOperation { throws OperationCancelledException { Log_OC.d(TAG, "Checking name collision in server"); - if (existsFile(client, mRemotePath, fileNames, encrypted)) { + boolean isFileExists = existsFile(client, mRemotePath, fileNames, encrypted); + + if (isFileExists) { switch (mNameCollisionPolicy) { - case CANCEL: - Log_OC.d(TAG, "File exists; canceling"); - throw new OperationCancelledException(); + 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; @@ -1244,7 +1246,11 @@ public class UploadFileOperation extends SyncOperation { OwnCloudClient client) { switch (mLocalBehaviour) { case FileUploadWorker.LOCAL_BEHAVIOUR_DELETE: - originalFile.delete(); + 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); @@ -1430,21 +1436,37 @@ public class UploadFileOperation extends SyncOperation { } /** - * Allows to cancel the actual upload operation. If actual upload operating is in progress it is cancelled, if - * upload preparation is being performed upload will not take place. + * 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) { - if (mUploadStarted.get()) { - Log_OC.d(TAG, "Cancelling upload during upload preparations."); - mCancellationRequested.set(true); - } else { - mCancellationRequested.set(true); - Log_OC.e(TAG, "No upload in progress. This should not happen."); - } - } else { + 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."); + } } } @@ -1548,18 +1570,18 @@ public class UploadFileOperation extends SyncOperation { if (!targetFile.equals(sourceFile)) { File expectedFolder = targetFile.getParentFile(); - expectedFolder.mkdirs(); + Files.createDirectories(expectedFolder.toPath()); if (expectedFolder.isDirectory()) { if (!sourceFile.renameTo(targetFile)) { // try to copy and then delete - targetFile.createNewFile(); + Files.createFile(targetFile.toPath()); try ( FileChannel inChannel = new FileInputStream(sourceFile).getChannel(); FileChannel outChannel = new FileOutputStream(targetFile).getChannel() ) { inChannel.transferTo(0, inChannel.size(), outChannel); - sourceFile.delete(); + Files.delete(sourceFile.toPath()); } catch (Exception e) { mFile.setStoragePath(""); // forget the local file // by now, treat this as a success; the file was uploaded @@ -1652,5 +1674,4 @@ public class UploadFileOperation extends SyncOperation { void onRenameUpload(); } - } diff --git a/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java b/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java index 246672f..2ec6a71 100644 --- a/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java +++ b/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java @@ -29,6 +29,7 @@ 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; @@ -78,7 +79,7 @@ public class DiskLruImageCacheFileProvider extends ContentProvider { // create a file to write bitmap data File f = new File(MainApp.getAppContext().getCacheDir(), ocFile.getFileName()); try { - f.createNewFile(); + Files.createFile(f.toPath()); //Convert bitmap to byte array ByteArrayOutputStream bos = new ByteArrayOutputStream(); diff --git a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt index 91367c7..ab22b2c 100644 --- a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt +++ b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt @@ -138,30 +138,34 @@ class AvatarGroupLayout @JvmOverloads constructor( avatar: ImageView, viewThemeUtils: ViewThemeUtils ) { - // maybe federated share - val split = user.split("@".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val userId: String? = split[0] - val server = split[1] + val split = user.split("@") + val userId = split.getOrNull(0) ?: user + val server = split.getOrNull(1) - val url = "https://" + server + "/index.php/avatar/" + userId + "/" + - resources.getInteger(R.integer.file_avatar_px) - var placeholder: Drawable? - try { - placeholder = TextDrawable.createAvatarByUserId(userId, avatarRadius) + 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) - placeholder = viewThemeUtils.platform.colorDrawable( - ResourcesCompat.getDrawable( - resources, - R.drawable.account_circle_white, - null - )!!, + viewThemeUtils.platform.colorDrawable( + ResourcesCompat + .getDrawable(resources, R.drawable.account_circle_white, null)!!, ContextCompat.getColor(context, R.color.black) ) } avatar.tag = null - loadCircularBitmapIntoImageView(context, url, avatar, placeholder) + if (url != null) { + loadCircularBitmapIntoImageView(context, url, avatar, placeholder) + } else { + avatar.setImageDrawable(placeholder) + } } override fun avatarGenerated(avatarDrawable: Drawable?, callContext: Any) { diff --git a/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.java b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.java index 7fad98a..2795296 100644 --- a/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.java +++ b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.java @@ -7,6 +7,7 @@ */ package com.owncloud.android.ui; +import android.annotation.SuppressLint; import android.content.Context; import android.preference.SwitchPreference; import android.util.AttributeSet; @@ -54,7 +55,7 @@ public class ThemeableSwitchPreference extends SwitchPreference { for (int i = 0; i < viewGroup.getChildCount(); i++) { View child = viewGroup.getChildAt(i); - if (child instanceof Switch switchView) { + if (child instanceof @SuppressLint("UseSwitchCompatOrMaterialCode") Switch switchView) { viewThemeUtils.platform.colorSwitch(switchView); break; diff --git a/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java b/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java index 6c10373..f61121e 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java @@ -189,7 +189,7 @@ public class ActivitiesActivity extends DrawerActivity implements ActivityListIn } else { showEmptyContent(getString(R.string.server_not_reachable), getString(R.string.server_not_reachable_content)); - binding.emptyList.emptyListIcon.setImageResource(R.drawable.ic_cloud_sync_off); + binding.emptyList.emptyListIcon.setImageResource(R.drawable.ic_sync_off); } }); diff --git a/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApiImpl.java b/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApiImpl.java index d775bad..8ad1408 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApiImpl.java +++ b/app/src/main/java/com/owncloud/android/ui/activities/data/files/FilesServiceApiImpl.java @@ -7,6 +7,7 @@ */ package com.owncloud.android.ui.activities.data.files; +import android.annotation.SuppressLint; import android.content.Context; import android.os.AsyncTask; @@ -33,8 +34,8 @@ public class FilesServiceApiImpl implements FilesServiceApi { private static final String TAG = FilesServiceApiImpl.class.getSimpleName(); - private UserAccountManager accountManager; - private ClientFactory clientFactory; + private final UserAccountManager accountManager; + private final ClientFactory clientFactory; public FilesServiceApiImpl(UserAccountManager accountManager, ClientFactory clientFactory) { this.accountManager = accountManager; @@ -58,7 +59,7 @@ public class FilesServiceApiImpl implements FilesServiceApi { private OCFile remoteOcFile; private String errorMessage; // TODO: Figure out a better way to do this than passing a BaseActivity reference. - private final BaseActivity baseActivity; + @SuppressLint("StaticFieldLeak") private final BaseActivity baseActivity; private final String fileUrl; private final User user; private final UserAccountManager accountManager; 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 index 29aa804..2b0d1c0 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -31,6 +31,7 @@ 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; @@ -115,6 +116,7 @@ import java.util.Optional; import javax.inject.Inject; +import androidx.activity.OnBackPressedCallback; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -206,6 +208,12 @@ public abstract class DrawerActivity extends ToolbarActivity @Inject 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. */ @@ -1153,19 +1161,24 @@ public abstract class DrawerActivity extends ToolbarActivity } } - @Override - public void onBackPressed() { - if (isDrawerOpen()) { - closeDrawer(); - return; - } - Fragment fileDetailsSharingProcessFragment = - getSupportFragmentManager().findFragmentByTag(FileDetailsSharingProcessFragment.TAG); - if (fileDetailsSharingProcessFragment != null) { - ((FileDetailsSharingProcessFragment) fileDetailsSharingProcessFragment).onBackPressed(); - } else { - super.onBackPressed(); - } + 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 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 index 8d94e81..ac63db3 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ErrorsWhileCopyingHandlerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/ErrorsWhileCopyingHandlerActivity.java @@ -11,6 +11,7 @@ */ package com.owncloud.android.ui.activity; +import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.os.AsyncTask; @@ -194,6 +195,7 @@ public class ErrorsWhileCopyingHandlerActivity extends AppCompatActivity implem /** * Asynchronous task performing the move of all the local files to the ownCloud folder. */ + @SuppressLint("StaticFieldLeak") private class MoveFilesTask extends AsyncTask { /** 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 index 944b15a..1e17272 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java @@ -129,6 +129,7 @@ public abstract class FileActivity extends DrawerActivity 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"; @@ -333,6 +334,7 @@ public abstract class FileActivity extends DrawerActivity * * @return Main {@link OCFile} handled by the activity. */ + @Nullable public OCFile getFile() { return mFile; } @@ -653,6 +655,7 @@ public abstract class FileActivity extends DrawerActivity return fileUploadHelper; } + @Nullable public OCFile getCurrentDir() { OCFile file = getFile(); if (file != null) { 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 index 87ef694..82dc1ea 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -40,6 +40,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.view.WindowManager.BadTokenException +import androidx.activity.OnBackPressedCallback import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView import androidx.core.view.MenuItemCompat @@ -82,6 +83,7 @@ import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.isActive import com.nextcloud.utils.extensions.lastFragment import com.nextcloud.utils.extensions.logFileSize +import com.nextcloud.utils.extensions.navigateToAllFiles import com.nextcloud.utils.fileNameValidator.FileNameValidator.checkFolderPath import com.nextcloud.utils.view.FastScrollUtils import com.owncloud.android.MainApp @@ -155,7 +157,6 @@ import com.owncloud.android.utils.PermissionUtil.requestNotificationPermission import com.owncloud.android.utils.PushUtils import com.owncloud.android.utils.StringUtils import com.owncloud.android.utils.theme.CapabilityUtils -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -278,6 +279,7 @@ class FileDisplayActivity : initSyncBroadcastReceiver() observeWorkerState() startMetadataSyncForRoot() + handleBackPress() } private fun loadSavedInstanceState(savedInstanceState: Bundle?) { @@ -429,7 +431,8 @@ class FileDisplayActivity : CapabilityUtils.checkOutdatedWarning( getResources(), user.get().server.version, - capabilities.extendedSupport.isTrue + capabilities.extendedSupport.isTrue, + capabilities.hasValidSubscription.isTrue ) ) { DisplayUtils.showServerOutdatedSnackbar(this, Snackbar.LENGTH_LONG) @@ -549,7 +552,7 @@ class FileDisplayActivity : when { ACTION_DETAILS.equals(action, ignoreCase = true) -> { - val file = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + val file = getFileFromIntent(intent) setFile(file) showDetails(file) } @@ -578,12 +581,12 @@ class FileDisplayActivity : when (intent.action) { Intent.ACTION_VIEW -> handleOpenFileViaIntent(intent) OPEN_FILE -> { - supportFragmentManager.executePendingTransactions() onOpenFileIntent(intent) } } } + @SuppressLint("UnsafeIntentLaunch") private fun handleRestartIntent(intent: Intent) { if (intent.action != RESTART) { return @@ -645,18 +648,41 @@ class FileDisplayActivity : // endregion private fun onOpenFileIntent(intent: Intent) { - val extra = intent.getStringExtra(EXTRA_FILE) - val file = storageManager.getFileByDecryptedRemotePath(extra) - if (file != null) { - val fileFragment: OCFileListFragment? - val leftFragment = this.leftFragment - if (leftFragment is OCFileListFragment) { - fileFragment = leftFragment - } else { - fileFragment = OCFileListFragment() - this.leftFragment = fileFragment + val file = getFileFromIntent(intent) + if (file == null) { + Log_OC.e(TAG, "Can't open file intent, file is null") + return + } + + val currentFragment = leftFragment + + if (currentFragment == null) { + Log_OC.e(TAG, "Can't open file intent, left fragment is null") + return + } + + val fileListFragment: OCFileListFragment = when { + currentFragment is OCFileListFragment && currentFragment !is GalleryFragment -> { + currentFragment } - fileFragment.onItemClicked(file) + + else -> { + Log_OC.w( + TAG, + "Left fragment is not a valid OCFileListFragment " + + "(was ${currentFragment::class.simpleName}). " + + "Replacing with OCFileListFragment." + ) + val newFragment = OCFileListFragment() + setLeftFragment(newFragment, false) + setupHomeSearchToolbarWithSortAndListButtons() + newFragment + } + } + + // Post to main thread to ensure fragment is fully attached before interacting + Handler(Looper.getMainLooper()).post { + fileListFragment.onItemClicked(file) } } @@ -935,7 +961,7 @@ class FileDisplayActivity : ) { openDrawer() } else { - onBackPressed() + onBackPressedDispatcher.onBackPressed() } } else if (itemId == R.id.action_select_all) { val fragment = this.listOfFilesFragment @@ -1046,7 +1072,7 @@ class FileDisplayActivity : localBasePath = "$localBasePath/" } - val remotePathBase = getCurrentDir().remotePath + val remotePathBase = getCurrentDir()?.remotePath val decryptedRemotePaths = getRemotePaths(remotePathBase, filePaths, localBasePath) val behaviour = when (resultCode) { @@ -1057,8 +1083,8 @@ class FileDisplayActivity : connectivityService.isNetworkAndServerAvailable { result: Boolean? -> if (result == true) { - val isValidFolderPath = checkFolderPath(remotePathBase, capabilities, this) - if (!isValidFolderPath) { + val isValidFolderPath = remotePathBase?.let { checkFolderPath(it, capabilities, this) } + if (isValidFolderPath == false) { DisplayUtils.showSnackMessage( this, R.string.file_name_validator_error_contains_reserved_names_or_invalid_characters @@ -1136,40 +1162,59 @@ class FileDisplayActivity : } } - private val isRootDirectory: Boolean - get() { - val currentDir = getCurrentDir() - return (currentDir == null || currentDir.parentId == FileDataStorageManager.ROOT_PARENT_ID.toLong()) - } - - /* - * BackPressed priority/hierarchy: - * 1. close search view if opened - * 2. close drawer if opened - * 3. if it is OCFileListFragment and it's in Root -> (finish Activity) or it's not Root -> (browse up) - * 4. otherwise pop up the fragment and sortGroup view visibility and call super.onBackPressed() + /** + * 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. */ - @SuppressFBWarnings("ITC_INHERITANCE_TYPE_CHECKING") // TODO Apply fail fast principle - override fun onBackPressed() { - if (isSearchOpen()) { - resetSearchAction() - return - } + private fun handleBackPress() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when { + isSearchOpen() -> { + isEnabled = false + resetSearchAction() + } - if (isDrawerOpen) { - super.onBackPressed() - return - } + isDrawerOpen -> { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } - if (this.leftFragment is OCFileListFragment) { - if (isRoot(getCurrentDir())) { - finish() - } else { - browseUp(leftFragment as OCFileListFragment) + leftFragment is OCFileListFragment -> { + val fragment = leftFragment as OCFileListFragment + if (isRoot(getCurrentDir())) { + if (fragment.shouldNavigateBackToAllFiles()) { + navigateToAllFiles() + } else { + finish() + } + } else { + browseUp(fragment) + } + } + + else -> { + isEnabled = false + popBack() + } + } + } } - } else { - popBack() - } + ) } private fun browseUp(listOfFiles: OCFileListFragment) { @@ -1177,8 +1222,12 @@ class FileDisplayActivity : val currentFile = listOfFiles.currentFile file = currentFile - listOfFiles.setFabVisible(currentFile.canCreateFileAndFolder()) - listOfFiles.registerFabListener() + + currentFile?.let { + listOfFiles.setFabVisible(currentFile.canCreateFileAndFolder()) + listOfFiles.registerFabListener() + } + resetTitleBarAndScrolling() configureToolbar() startMetadataSyncForCurrentDir() @@ -1196,9 +1245,10 @@ class FileDisplayActivity : if (isRoot(getCurrentDir()) && leftFragment is OCFileListFragment) { // Remove the list to the original state - - val listOfHiddenFiles = leftFragment.adapter.listOfHiddenFiles - leftFragment.performSearch("", listOfHiddenFiles, true) + leftFragment.adapter?.let { adapter -> + val listOfHiddenFiles = adapter.listOfHiddenFiles + leftFragment.performSearch("", listOfHiddenFiles, true) + } hideSearchView(getCurrentDir()) setDrawerIndicatorEnabled(isDrawerIndicatorAvailable) @@ -1206,20 +1256,20 @@ class FileDisplayActivity : if (leftFragment is UnifiedSearchFragment) { showSortListGroup(false) - super.onBackPressed() + onBackPressedDispatcher.onBackPressed() } } /** * 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 super.onBackPressed() + * [with false][.showSortListGroup]. At last call to onBackPressedDispatcher.onBackPressed() */ private fun popBack() { binding.fabMain.setImageResource(R.drawable.ic_plus) resetScrolling(true) showSortListGroup(false) - super.onBackPressed() + onBackPressedDispatcher.onBackPressed() } override fun onSaveInstanceState(outState: Bundle) { @@ -1263,13 +1313,11 @@ class FileDisplayActivity : val ocFileListFragment = leftFragment syncAndUpdateFolder(ignoreETag = true, ignoreFocus = true) - var startFile: OCFile? = null - if (intent != null) { - val fileArgs = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - if (fileArgs != null) { - startFile = fileArgs - file = startFile - } + // 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 @@ -1310,6 +1358,11 @@ class FileDisplayActivity : }, 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) } + private fun checkAndSetMenuItemId() { if (MainApp.isOnlyPersonFiles()) { menuItemId = R.id.nav_personal_files @@ -1442,9 +1495,8 @@ class FileDisplayActivity : return } - var currentFile = if (file == null) null else storageManager.getFileByPath(file.remotePath) - val currentDir = - if (getCurrentDir() == null) null else storageManager.getFileByPath(getCurrentDir().remotePath) + 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) { @@ -1468,7 +1520,7 @@ class FileDisplayActivity : } private fun handleRemovedFileFromServer(currentFile: OCFile?, currentDir: OCFile?): OCFile? { - if (currentFile == null && !file.isFolder) { + if (currentFile == null && file?.isFolder == false) { resetTitleBarAndScrolling() return currentDir } @@ -1617,8 +1669,8 @@ class FileDisplayActivity : var sameFile = false if (file != null) { renamedInUpload = - file.remotePath == intent.getStringExtra(FileUploadWorker.EXTRA_OLD_REMOTE_PATH) - sameFile = file.remotePath == uploadedRemotePath || renamedInUpload + file?.remotePath == intent.getStringExtra(FileUploadWorker.EXTRA_OLD_REMOTE_PATH) + sameFile = file?.remotePath == uploadedRemotePath || renamedInUpload } if (sameAccount && sameFile && this@FileDisplayActivity.leftFragment is FileDetailFragment) { @@ -1638,21 +1690,21 @@ class FileDisplayActivity : ) } - if (uploadWasFine || file != null && file.fileExists()) { + if (uploadWasFine || file?.fileExists() == true) { fileDetailFragment.updateFileDetails(false, true) } else { - onBackPressed() + onBackPressedDispatcher.onBackPressed() } // Force the preview if the file is an image or text file if (uploadWasFine) { - val ocFile = file - if (PreviewImageFragment.canBePreviewed(ocFile)) { - startImagePreview(file, true) - } else if (PreviewTextFileFragment.canBePreviewed(ocFile)) { - startTextPreview(ocFile, true) + file?.let { + if (PreviewImageFragment.canBePreviewed(it)) { + startImagePreview(it, true) + } else if (PreviewTextFileFragment.canBePreviewed(it)) { + startTextPreview(it, true) + } } - // TODO what about other kind of previews? } } } @@ -1910,7 +1962,7 @@ class FileDisplayActivity : if (getCurrentDir() != null ) { - storageManager.getFileByDecryptedRemotePath(getCurrentDir().remotePath) + storageManager.getFileByDecryptedRemotePath(getCurrentDir()?.remotePath) } else { null } @@ -2080,17 +2132,17 @@ class FileDisplayActivity : val file = getFile() // delete old local copy - if (file.isDown) { + 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) + showSyncLoadingDialog(file.isFolder == true) fileOperationsHelper.syncFile(file) } - val parent = storageManager.getFileById(file.parentId) + val parent = file?.let { storageManager.getFileById(it.parentId) } startSyncFolderOperation(parent, ignoreETag = true, ignoreFocus = true) val leftFragment = this.leftFragment @@ -2654,11 +2706,7 @@ class FileDisplayActivity : return } - val folder = listOfFiles.currentFile - if (folder == null) { - return - } - + val folder = listOfFiles.currentFile ?: return startSyncFolderOperation(folder, ignoreETag, ignoreFocus) } @@ -2927,7 +2975,18 @@ class FileDisplayActivity : } fun performUnifiedSearch(query: String, listOfHiddenFiles: ArrayList?) { - val unifiedSearchFragment = UnifiedSearchFragment.Companion.newInstance(query, listOfHiddenFiles) + val path = currentDir?.decryptedRemotePath + ?: run { + Log_OC.w(TAG, "currentDir is null, using ROOT_PATH") + OCFile.ROOT_PATH + } + + val unifiedSearchFragment = + UnifiedSearchFragment.Companion.newInstance( + query, + listOfHiddenFiles, + path + ) setLeftFragment(unifiedSearchFragment, false) } 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 index 85ead7b..7599a5b 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt @@ -21,7 +21,9 @@ 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.fileNameValidator.FileNameValidator import com.owncloud.android.R @@ -48,6 +50,8 @@ 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 @@ -60,7 +64,6 @@ open class FolderPickerActivity : OnSortingOrderListener { private var mSyncBroadcastReceiver: SyncBroadcastReceiver? = null - private var mSyncInProgress = false private var mSearchOnlyFolders = false var isDoNotEnterEncryptedFolder = false private set @@ -105,8 +108,7 @@ open class FolderPickerActivity : } updateActionBarTitleAndHomeButtonByString(captionText) - setBackgroundText() - handleOnBackPressed() + handleBackPress() } override fun onDestroy() { @@ -151,7 +153,7 @@ open class FolderPickerActivity : } } - private fun handleOnBackPressed() { + private fun handleBackPress() { onBackPressedDispatcher.addCallback( this, object : OnBackPressedCallback(true) { @@ -211,25 +213,6 @@ open class FolderPickerActivity : transaction.commit() } - /** - * Show a text message on screen view for notifying user if content is loading or folder is empty - */ - private fun setBackgroundText() { - val listFragment = listOfFilesFragment - - if (listFragment == null) { - Log_OC.e(TAG, "OCFileListFragment is null") - } - - listFragment?.let { - if (mSyncInProgress) { - it.setEmptyListMessage(EmptyListState.LOADING) - } else { - it.setEmptyListMessage(EmptyListState.ADD_FOLDER) - } - } - } - protected val listOfFilesFragment: OCFileListFragment? get() { val listOfFiles = supportFragmentManager.findFragmentByTag(TAG_LIST_OF_FOLDERS) @@ -259,21 +242,33 @@ open class FolderPickerActivity : } private fun startSyncFolderOperation(folder: OCFile?, ignoreETag: Boolean) { - val currentSyncTime = System.currentTimeMillis() - mSyncInProgress = true - - RefreshFolderOperation( - folder, - currentSyncTime, - false, - ignoreETag, - storageManager, - user.orElseThrow { RuntimeException("User not set") }, - applicationContext - ).also { - it.execute(account, this, null, null) + 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 + ) } - setBackgroundText() } override fun onResume() { @@ -327,7 +322,7 @@ open class FolderPickerActivity : } else if (itemId == android.R.id.home) { val currentDir = currentFolder if (currentDir != null && currentDir.parentId != 0L) { - onBackPressed() + onBackPressedDispatcher.onBackPressed() } } else { retval = super.onOptionsItemSelected(item) @@ -414,10 +409,10 @@ open class FolderPickerActivity : file?.isFolder != true -> true // all of the target files are already in the selected directory - targetFilePaths?.all { PathUtils.isDirectParent(file.remotePath, it) } == true -> false + 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 + targetFilePaths?.any { PathUtils.isAncestor(it, file?.remotePath ?: "") } == true -> false else -> true } @@ -434,7 +429,7 @@ open class FolderPickerActivity : } private fun getSelectedFolderPathTitle(): String? { - val atRoot = (currentDir == null || currentDir.parentId == 0L) + val atRoot = (currentDir == null || currentDir?.parentId == 0L) return if (atRoot) captionText ?: "" else currentDir?.fileName } @@ -554,15 +549,13 @@ open class FolderPickerActivity : return } - if (FileSyncAdapter.EVENT_FULL_SYNC_START == event) { - mSyncInProgress = true - } else { + if (FileSyncAdapter.EVENT_FULL_SYNC_START != event) { var (currentFile, currentDir) = getCurrentFileAndDirectory() if (currentDir == null) { browseRootForRemovedFolder() } else { - if (currentFile == null && !file.isFolder) { + if (currentFile == null && file?.isFolder == false) { // currently selected file was removed in the server, and now we know it currentFile = currentDir } @@ -572,28 +565,22 @@ open class FolderPickerActivity : file = currentFile } - mSyncInProgress = ( - FileSyncAdapter.EVENT_FULL_SYNC_END != event && - RefreshFolderOperation.EVENT_SINGLE_FOLDER_SHARES_SYNCED != event - ) - checkCredentials(syncResult, event) } DataHolderUtil.getInstance().delete(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT)) - Log_OC.d(TAG, "Setting progress visibility to $mSyncInProgress") - setBackgroundText() } 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 = - if (file == null) null else storageManager.getFileByEncryptedRemotePath(file.remotePath) + val currentFile = file?.let { storageManager.getFileByEncryptedRemotePath(it.remotePath) } val currentDir = if (currentFolder == null) { null 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 index 8ae77e6..89d37b8 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt @@ -201,7 +201,7 @@ class InternalTwoWaySyncActivity : override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { - onBackPressed() + onBackPressedDispatcher.onBackPressed() } R.id.action_dismiss_two_way_sync -> { disableTwoWaySyncAndWorkers() 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 index 65c4d07..ef3526e 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt @@ -91,7 +91,7 @@ class ManageAccountsActivity : multipleAccountsSupported = multiAccountSupport(this) setupUserList() - handleOnBackPressed() + handleBackPress() } private fun setupUsers() { @@ -149,7 +149,7 @@ class ManageAccountsActivity : performAccountRemoval(user) } - private fun handleOnBackPressed() { + private fun handleBackPress() { onBackPressedDispatcher.addCallback( this, onBackPressedCallback @@ -160,13 +160,13 @@ class ManageAccountsActivity : override fun handleOnBackPressed() { val resultIntent = Intent() - if (accountManager.allUsers.size > 0) { + 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.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP startActivity(intent) } @@ -229,7 +229,7 @@ class ManageAccountsActivity : var result = true if (item.itemId == android.R.id.home) { - onBackPressed() + onBackPressedDispatcher.onBackPressed() } else { result = super.onOptionsItemSelected(item) } @@ -331,7 +331,7 @@ class ManageAccountsActivity : ) recyclerView?.adapter = userListAdapter } else { - onBackPressed() + onBackPressedDispatcher.onBackPressed() } } @@ -393,8 +393,7 @@ class ManageAccountsActivity : resultIntent.putExtra(KEY_ACCOUNT_LIST_CHANGED, true) resultIntent.putExtra(KEY_CURRENT_ACCOUNT_CHANGED, true) setResult(RESULT_OK, resultIntent) - - super.onBackPressed() + onBackPressedDispatcher.onBackPressed() } } 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 index c9b4be9..be8688e 100755 --- a/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java @@ -103,6 +103,7 @@ 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; @@ -147,7 +148,6 @@ public class ReceiveExternalFilesActivity extends FileActivity private SyncBroadcastReceiver mSyncBroadcastReceiver; private ReceiveExternalFilesAdapter receiveExternalFilesAdapter; - private boolean mSyncInProgress; private final static int REQUEST_CODE__SETUP_ACCOUNT = REQUEST_CODE__LAST_SHARED + 1; @@ -203,6 +203,8 @@ public class ReceiveExternalFilesActivity extends FileActivity fm.beginTransaction() .add(taskRetainerFragment, TaskRetainerFragment.FTAG_TASK_RETAINER_FRAGMENT).commit(); } // else, Fragment already created and retained across configuration change + + handleBackPress(); } @Override @@ -656,14 +658,19 @@ public class ReceiveExternalFilesActivity extends FileActivity } } - @Override - public void onBackPressed() { - if (mParents.size() <= SINGLE_PARENT) { - super.onBackPressed(); - } else { - mParents.pop(); - browseToFolderIfItExists(); - } + 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 @@ -848,7 +855,6 @@ public class ReceiveExternalFilesActivity extends FileActivity executorService.execute(() -> { long currentSyncTime = System.currentTimeMillis(); - mSyncInProgress = true; final var optionalUser = getUser(); if (optionalUser.isEmpty()) { DisplayUtils.showSnackMessage(this, R.string.user_information_retrieval_error); @@ -874,7 +880,9 @@ public class ReceiveExternalFilesActivity extends FileActivity private List sortFileList(List files) { FileSortOrder sortOrder = preferences.getSortOrderByFolder(mFile); - return sortOrder.sortCloudFiles(files); + boolean foldersBeforeFiles = preferences.isSortFoldersBeforeFiles(); + boolean favoritesFirst = preferences.isSortFavoritesFirst(); + return sortOrder.sortCloudFiles(files, foldersBeforeFiles, favoritesFirst); } private String generatePath(Stack dirs) { @@ -944,7 +952,7 @@ public class ReceiveExternalFilesActivity extends FileActivity } if (mStreamsToUpload.size() > FileUploadHelper.MAX_FILE_COUNT) { - DisplayUtils.showSnackMessage(this, R.string.max_file_count_warning_message); + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); return; } @@ -1097,7 +1105,7 @@ public class ReceiveExternalFilesActivity extends FileActivity dialog.show(getSupportFragmentManager(), CreateFolderDialogFragment.CREATE_FOLDER_FRAGMENT); } else if (itemId == android.R.id.home) { if (mParents.size() > SINGLE_PARENT) { - onBackPressed(); + getOnBackPressedDispatcher().onBackPressed(); } } else if (itemId == R.id.action_switch_account) { showAccountChooserDialog(); @@ -1145,58 +1153,32 @@ public class ReceiveExternalFilesActivity extends FileActivity boolean sameAccount = getAccount() != null && accountName.equals(getAccount().name) && getStorageManager() != null; - if (sameAccount) { - - if (FileSyncAdapter.EVENT_FULL_SYNC_START.equals(event)) { - mSyncInProgress = true; + 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 { - 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 (currentFile == null && !mFile.isFolder()) { + // currently selected file was removed in the server, and now we know it + currentFile = currentDir; } - mSyncInProgress = !FileSyncAdapter.EVENT_FULL_SYNC_END.equals(event) && - !RefreshFolderOperation.EVENT_SINGLE_FOLDER_SHARES_SYNCED.equals(event); - - if (RefreshFolderOperation.EVENT_SINGLE_FOLDER_CONTENTS_SYNCED.equals(event) - /// TODO refactor and make common - && 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); - } + if (currentDir.getRemotePath().equals(syncFolderRemotePath)) { + populateDirectoryList(currentFile); } } - Log_OC.d(TAG, "Setting progress visibility to " + mSyncInProgress); + 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 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 index 6d979f0..c97cca0 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt @@ -185,7 +185,7 @@ class RichDocumentsEditorWebView : EditorWebView() { renameString ?: return val renameJson = JSONObject(renameString) val newName = renameJson.getString(NEW_NAME) - file.fileName = newName + file?.fileName = newName } catch (e: JSONException) { Log_OC.e(this, "Failed to parse rename json message: $e") } 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 index bcbed6b..5cf6399 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java @@ -15,6 +15,7 @@ */ package com.owncloud.android.ui.activity; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; import android.content.SharedPreferences; @@ -183,6 +184,9 @@ public class SettingsActivity extends PreferenceActivity // Synced folders setupAutoUploadCategory(preferenceScreen); + // Files + setupFilesCategory(); + // Details setupDetailsCategory(preferenceScreen); @@ -354,6 +358,7 @@ public class SettingsActivity extends PreferenceActivity } } + @SuppressLint("GestureBackNavigation") @Override public void onBackPressed() { DrawerActivity.menuItemId = R.id.nav_all_files; @@ -661,25 +666,33 @@ public class SettingsActivity extends PreferenceActivity boolean fPassCodeEnabled = getResources().getBoolean(R.bool.passcode_enabled); boolean fDeviceCredentialsEnabled = getResources().getBoolean(R.bool.device_credentials_enabled); - boolean fShowHiddenFilesEnabled = getResources().getBoolean(R.bool.show_hidden_files_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); - setupHiddenFilesPreference(preferenceCategoryDetails, fShowHiddenFilesEnabled); - setupShowEcosystemAppsPreference(preferenceCategoryDetails, fShowEcosystemAppsEnabled); setupShowMediaScanNotifications(preferenceCategoryDetails, fShowMediaScanNotifications); - if (!fPassCodeEnabled && !fDeviceCredentialsEnabled && !fShowHiddenFilesEnabled && fSyncedFolderLightEnabled + 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 = @@ -703,6 +716,22 @@ public class SettingsActivity extends PreferenceActivity } } + 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) { @@ -1148,7 +1177,7 @@ public class SettingsActivity extends PreferenceActivity for (final ExternalLink link : externalLinksProvider.getExternalLink(ExternalLinkType.SETTINGS)) { - // only add if it does not exist, in case activity is re-used + // only add if it does not exist, in case activity is reused if (findPreference(String.valueOf(link.getId())) == null) { Preference p = new Preference(this); p.setTitle(link.getName()); 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 index 4fd0eee..7992adb 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/StorageMigration.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/StorageMigration.java @@ -11,11 +11,10 @@ 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.content.DialogInterface; -import android.content.DialogInterface.OnClickListener; import android.os.AsyncTask; import android.view.View; @@ -29,6 +28,8 @@ 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; @@ -175,7 +176,7 @@ public class StorageMigration { private static abstract class FileMigrationTaskBase extends AsyncTask { protected String mStorageSource; protected String mStorageTarget; - protected Context mContext; + @SuppressLint("StaticFieldLeak") protected Context mContext; protected User user; protected ProgressDialog mProgressDialog; protected StorageMigrationProgressListener mListener; @@ -364,10 +365,19 @@ public class StorageMigration { try { File dstFile = new File(mStorageTarget + File.separator + MainApp.getDataFolder()); deleteRecursive(dstFile); - dstFile.delete(); + 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()); - srcFile.mkdirs(); + + 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); @@ -466,7 +476,11 @@ public class StorageMigration { if (!deleteRecursive(srcFile)) { Log_OC.w(TAG, "Migration cleanup step failed"); } - srcFile.delete(); + 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) { 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 index ca272e9..ad899f6 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt @@ -28,6 +28,7 @@ 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 @@ -146,6 +147,9 @@ class SyncedFoldersActivity : @Inject lateinit var syncedFolderProvider: SyncedFolderProvider + @Inject + lateinit var appInfo: AppInfo + lateinit var binding: SyncedFoldersLayoutBinding lateinit var adapter: SyncedFolderAdapter @@ -199,21 +203,6 @@ class SyncedFoldersActivity : override fun onCreateOptionsMenu(menu: Menu): Boolean { val inflater = menuInflater inflater.inflate(R.menu.activity_synced_folders, menu) - if (powerManagementService.isPowerSavingExclusionAvailable) { - val item = menu.findItem(R.id.action_disable_power_save_check) - item.isVisible = true - item.isChecked = preferences.isPowerCheckDisabled - item.setOnMenuItemClickListener { powerCheck -> onDisablePowerSaveCheckClicked(powerCheck) } - } - return true - } - - private fun onDisablePowerSaveCheckClicked(powerCheck: MenuItem): Boolean { - if (!powerCheck.isChecked) { - showPowerCheckDialog() - } - preferences.isPowerCheckDisabled = !powerCheck.isChecked - powerCheck.isChecked = !powerCheck.isChecked return true } @@ -245,7 +234,9 @@ class SyncedFoldersActivity : gridWidth, this, lightVersion, - viewThemeUtils + viewThemeUtils, + powerManagementService, + connectivityService ) binding.emptyList.emptyListIcon.setImageResource(R.drawable.nav_synced_folders) viewThemeUtils.material.colorMaterialButtonPrimaryFilled(binding.emptyList.emptyListViewAction) @@ -575,7 +566,7 @@ class SyncedFoldersActivity : } } if (syncedFolderDisplayItem.isEnabled) { - backgroundJobManager.startImmediateFilesSyncJob(syncedFolderDisplayItem.id, overridePowerSaving = false) + backgroundJobManager.startAutoUploadImmediately(syncedFolderDisplayItem, overridePowerSaving = false) showBatteryOptimizationInfo() } } @@ -738,7 +729,7 @@ class SyncedFoldersActivity : // existing synced folder setup to be updated syncedFolderProvider.updateSyncFolder(item) if (item.isEnabled) { - backgroundJobManager.startImmediateFilesSyncJob(item.id, overridePowerSaving = false) + backgroundJobManager.startAutoUploadImmediately(item, overridePowerSaving = false) } else { val syncedFolderInitiatedKey = KEY_SYNCED_FOLDER_INITIATED_PREFIX + item.id val arbitraryDataProvider = @@ -755,7 +746,7 @@ class SyncedFoldersActivity : if (storedId != -1L) { item.id = storedId if (item.isEnabled) { - backgroundJobManager.startImmediateFilesSyncJob(item.id, overridePowerSaving = false) + backgroundJobManager.startAutoUploadImmediately(item, overridePowerSaving = false) } else { val syncedFolderInitiatedKey = KEY_SYNCED_FOLDER_INITIATED_PREFIX + item.id arbitraryDataProvider.deleteKeyForAccount("global", syncedFolderInitiatedKey) @@ -835,7 +826,7 @@ class SyncedFoldersActivity : } private fun showBatteryOptimizationInfo() { - if (powerManagementService.isPowerSavingExclusionAvailable || checkIfBatteryOptimizationEnabled()) { + if (checkIfBatteryOptimizationEnabled()) { val alertDialogBuilder = MaterialAlertDialogBuilder(this, R.style.Theme_ownCloud_Dialog) .setTitle(getString(R.string.battery_optimization_title)) .setMessage(getString(R.string.battery_optimization_message)) @@ -871,7 +862,7 @@ class SyncedFoldersActivity : val powerManager = getSystemService(POWER_SERVICE) as PowerManager? return when { powerManager != null -> !powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) - else -> true + else -> !appInfo.isDebugBuild } } } 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 index 3a60eec..7cf73eb 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt @@ -43,7 +43,7 @@ class TextEditorWebView : EditorWebView() { finish() } - val editor = editorUtils.getEditor(user.get(), file.mimeType) + val editor = editorUtils.getEditor(user.get(), file?.mimeType) if (editor != null && editor.id == "onlyoffice") { webView.settings.userAgentString = generateOnlyOfficeUserAgent() 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 index 73d5148..f494589 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java @@ -157,17 +157,32 @@ public abstract class ToolbarActivity extends BaseActivity implements Injectable * Updates title bar and home buttons (state and icon). */ protected void updateActionBarTitleAndHomeButton(OCFile chosenFile) { - String title; boolean isRoot = isRoot(chosenFile); - - title = isRoot ? themeUtils.getDefaultDisplayNameForRootFolder(this) : fileDataStorageManager.getFilenameConsideringOfflineOperation(chosenFile); + String title = getActionBarTitle(chosenFile, isRoot); updateActionBarTitleAndHomeButtonByString(title); - if (mAppBar != null) { showHomeSearchToolbar(title, isRoot); } } + private String getActionBarTitle(OCFile chosenFile, boolean isRoot) { + if (isRoot) { + return themeUtils.getDefaultDisplayNameForRootFolder(this); + } + + if (chosenFile.isFolder()) { + return fileDataStorageManager.getFilenameConsideringOfflineOperation(chosenFile); + } + + long parentId = chosenFile.getParentId(); + OCFile parentFile = fileDataStorageManager.getFileById(parentId); + if (parentFile == null) { + return ""; + } + + return fileDataStorageManager.getFilenameConsideringOfflineOperation(parentFile); + } + public void showSearchView() { if (isHomeSearchToolbarShow) { showHomeSearchToolbar(false); @@ -194,7 +209,7 @@ public abstract class ToolbarActivity extends BaseActivity implements Injectable R.animator.appbar_elevation_off)); mDefaultToolbar.setVisibility(View.GONE); mHomeSearchToolbar.setVisibility(View.VISIBLE); - viewThemeUtils.material.themeCardView(mHomeSearchToolbar); + viewThemeUtils.material.themeSearchCardView(mHomeSearchToolbar); viewThemeUtils.material.themeSearchBarText(mSearchText); } else { mAppBar.setStateListAnimator(AnimatorInflater.loadStateListAnimator(mAppBar.getContext(), @@ -272,11 +287,21 @@ public abstract class ToolbarActivity extends BaseActivity implements Injectable } public void showSortListGroup(boolean show) { - findViewById(R.id.sort_list_button_group).setVisibility(show ? View.VISIBLE : View.GONE); + final var view = findViewById(R.id.sort_list_button_group); + if (view == null) { + return; + } + + view.setVisibility(show ? View.VISIBLE : View.GONE); } public boolean sortListGroupVisibility(){ - return findViewById(R.id.sort_list_button_group).getVisibility() == View.VISIBLE; + 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. 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 index 226bb6d..4cd80f5 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java @@ -238,7 +238,7 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList public void onItemSelected(AdapterView parent, View view, int position, long id) { int i = position; while (i-- != 0) { - onBackPressed(); + 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 @@ -311,7 +311,7 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList if (itemId == android.R.id.home) { if (mCurrentDir != null && mCurrentDir.getParentFile() != null) { - onBackPressed(); + getOnBackPressedDispatcher().onBackPressed(); } } else if (itemId == R.id.action_select_all) { mSelectAll = !item.isChecked(); @@ -503,7 +503,7 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList } else { final var chosenFiles = mFileListFragment.getCheckedFilePaths(); if (chosenFiles.length > FileUploadHelper.MAX_FILE_COUNT) { - DisplayUtils.showSnackMessage(this, R.string.max_file_count_warning_message); + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); return; } @@ -670,7 +670,7 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList } else { final var chosenFiles = mFileListFragment.getCheckedFilePaths(); if (chosenFiles.length > FileUploadHelper.MAX_FILE_COUNT) { - DisplayUtils.showSnackMessage(this, R.string.max_file_count_warning_message); + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); return; } boolean isPositionZero = (binding.uploadFilesSpinnerBehaviour.getSelectedItemPosition() == 0); @@ -720,7 +720,7 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList 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) { - DisplayUtils.showSnackMessage(this, R.string.max_file_count_warning_message); + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); return; } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.java index 49ef2bc..8d61cbd 100755 --- a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.java @@ -41,7 +41,6 @@ import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.operations.CheckCurrentCredentialsOperation; import com.owncloud.android.ui.adapter.UploadListAdapter; import com.owncloud.android.ui.decoration.MediaGridItemDecoration; -import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.FilesSyncHelper; import javax.inject.Inject; @@ -133,15 +132,13 @@ public class UploadListActivity extends FileActivity { WorkerStateLiveData.Companion.instance().observe(this, state -> { if (state instanceof WorkerState.UploadStarted) { Log_OC.d(TAG, "Upload worker started"); - handleUploadWorkerState(); + uploadListAdapter.loadUploadItemsFromDb(); + } else if (state instanceof WorkerState.UploadFinished) { + uploadListAdapter.loadUploadItemsFromDb(() -> swipeListRefreshLayout.setRefreshing(false)); } }); } - private void handleUploadWorkerState() { - uploadListAdapter.loadUploadItemsFromDb(); - } - private void setupContent() { binding.list.setEmptyView(binding.emptyList.getRoot()); binding.emptyList.getRoot().setVisibility(View.GONE); @@ -182,26 +179,15 @@ public class UploadListActivity extends FileActivity { } private void refresh() { - FilesSyncHelper.startFilesSyncForAllFolders(syncedFolderProvider, - backgroundJobManager, - true, - new String[]{}); + boolean isUploadStarted = FileUploadHelper.Companion.instance().retryFailedUploads( + uploadsStorageManager, + connectivityService, + accountManager, + powerManagementService); - if (uploadsStorageManager.getFailedUploads().length > 0) { - new Thread(() -> { - FileUploadHelper.Companion.instance().retryFailedUploads( - uploadsStorageManager, - connectivityService, - accountManager, - powerManagementService); - uploadListAdapter.loadUploadItemsFromDb(); - }).start(); - DisplayUtils.showSnackMessage(this, R.string.uploader_local_files_uploaded); + if (!isUploadStarted) { + uploadListAdapter.loadUploadItemsFromDb(() -> swipeListRefreshLayout.setRefreshing(false)); } - - - // update UI - uploadListAdapter.loadUploadItemsFromDb(() -> swipeListRefreshLayout.setRefreshing(false)); } @Override 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 index 613437a..11adefe 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java @@ -12,6 +12,7 @@ */ package com.owncloud.android.ui.activity; +import android.annotation.SuppressLint; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.os.Bundle; @@ -158,7 +159,7 @@ public class UserInfoActivity extends DrawerActivity implements Injectable { int itemId = item.getItemId(); if (itemId == android.R.id.home) { - onBackPressed(); + getOnBackPressedDispatcher().onBackPressed(); } else if (itemId == R.id.action_open_account) { accountClicked(user.hashCode()); } else if (itemId == R.id.action_delete_account) { @@ -392,6 +393,7 @@ public class UserInfoActivity extends DrawerActivity implements Injectable { this.viewThemeUtils = viewThemeUtils; } + @SuppressLint("NotifyDataSetChanged") public void setData(List displayList) { mDisplayList = displayList == null ? new LinkedList<>() : displayList; notifyDataSetChanged(); diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ActivityAndVersionListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/ActivityAndVersionListAdapter.java index 00f82c2..51c00db 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ActivityAndVersionListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ActivityAndVersionListAdapter.java @@ -8,6 +8,7 @@ */ package com.owncloud.android.ui.adapter; +import android.annotation.SuppressLint; import android.content.Context; import android.text.format.DateFormat; import android.view.LayoutInflater; @@ -54,6 +55,7 @@ public class ActivityAndVersionListAdapter extends ActivityListAdapter { this.versionListInterface = versionListInterface; } + @SuppressLint("NotifyDataSetChanged") public void setActivityAndVersionItems(List items, NextcloudClient newClient, boolean clear) { if (client == null) { client = newClient; diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java index 3565302..d824608 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java @@ -10,6 +10,7 @@ */ package com.owncloud.android.ui.adapter; +import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; @@ -98,6 +99,7 @@ public class ActivityListAdapter extends RecyclerView.Adapter activityItems, NextcloudClient client, boolean clear) { this.client = client; String sTime = ""; 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 index 8b210af..1b8d3cc 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -15,8 +15,6 @@ 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 @@ -31,6 +29,7 @@ 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 @@ -57,7 +56,28 @@ class GalleryAdapter( ) : SectionedRecyclerViewAdapter(), CommonOCFileListAdapterInterface, PopupTextProvider { - var files: List = mutableListOf() + + 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 @@ -78,7 +98,50 @@ class GalleryAdapter( ) } - override fun getItemId(section: Int, position: Int): Long = files[section].rows[position].calculateHashCode() + private fun invalidateCaches() { + Log_OC.d(TAG, "invalidating caches") + cachedAllFiles = null + updateFilesCount() + rebuildFilePositionMap() + } + + private fun updateFilesCount() { + cachedFilesCount = files.fold(0) { acc, item -> acc + item.rows.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) { @@ -116,16 +179,12 @@ class GalleryAdapter( relativePosition: Int, absolutePosition: Int ) { - if (holder != null) { - val rowHolder = holder as GalleryRowHolder - rowHolder.bind(files[section].rows[relativePosition]) + if (holder is GalleryRowHolder) { + val row = files.getOrNull(section)?.rows?.getOrNull(relativePosition) + row?.let { holder.bind(it) } } } - override fun getItemCount(section: Int): Int = files[section].rows.size - - override fun getSectionCount(): Int = files.size - override fun getPopupText(p0: View, position: Int): CharSequence = DisplayUtils.getDateByPattern( files[getRelativePosition(position).section()].date, context, @@ -150,10 +209,6 @@ class GalleryAdapter( } } - override fun onBindFooterViewHolder(holder: SectionedViewHolder?, section: Int) { - TODO("Not yet implemented") - } - @SuppressLint("NotifyDataSetChanged") fun showAllGalleryItems( remotePath: String, @@ -194,36 +249,32 @@ class GalleryAdapter( photoFragment.setEmptyListMessage(SearchType.GALLERY_SEARCH) } - Handler(Looper.getMainLooper()).post { - files = finalSortedList.toGalleryItems() - notifyDataSetChanged() - } + files = finalSortedList.toGalleryItems() + notifyDataSetChanged() } - private fun transformToRows(list: List): List = list - .sortedBy { it.modificationTimestamp } - .reversed() - .chunked(columns) - .map { entry -> GalleryRow(entry, defaultThumbnailSize, defaultThumbnailSize) } + 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() { - Handler(Looper.getMainLooper()).post { - files = emptyList() - notifyDataSetChanged() - } + files = emptyList() + notifyDataSetChanged() } - private fun firstOfMonth(timestamp: Long): Long { - val cal = Calendar.getInstance() - cal.time = Date(timestamp) - cal.set(Calendar.DAY_OF_MONTH, cal.getActualMinimum(Calendar.DAY_OF_MONTH)) - cal.set(Calendar.HOUR_OF_DAY, 0) - cal.set(Calendar.MINUTE, 0) - cal.set(Calendar.SECOND, 0) - - return cal.timeInMillis - } + 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() @@ -244,37 +295,6 @@ class GalleryAdapter( ocFileListDelegate.cancelAllPendingTasks() } - override fun getItemPosition(file: OCFile): Int { - val findResult = files - .asSequence() - .flatMapIndexed { itemIndex, item -> - item.rows.withIndex().map { row -> Triple(itemIndex, row.index, row.value) } - }.find { - it.third.files.contains(file) - } - - val (item, row) = findResult ?: Triple(0, 0, null) - return getAbsolutePosition(item, row) - } - - override fun swapDirectory( - user: User, - directory: OCFile, - storageManager: FileDataStorageManager, - onlyOnDevice: Boolean, - mLimitToMimeType: String - ) { - TODO("Not yet implemented") - } - - override fun setHighlightedItem(file: OCFile) { - TODO("Not yet implemented") - } - - override fun setSortOrder(mFile: OCFile, sortOrder: FileSortOrder) { - TODO("Not yet implemented") - } - override fun addCheckedFile(file: OCFile) { ocFileListDelegate.addCheckedFile(file) } @@ -288,23 +308,20 @@ class GalleryAdapter( } override fun notifyItemChanged(file: OCFile) { - notifyItemChanged(getItemPosition(file)) - } - - override fun getFilesCount(): Int = files.fold(0) { acc, item -> acc + item.rows.size } - - @SuppressLint("NotifyDataSetChanged") - override fun setMultiSelect(boolean: Boolean) { - ocFileListDelegate.isMultiSelect = boolean - notifyDataSetChanged() - } - - private fun getAllFiles(): List = files.flatMap { galleryItem -> - galleryItem.rows.flatMap { row -> - row.files + val position = getItemPosition(file) + if (position >= 0) { + notifyItemChanged(position) } } + override fun setMultiSelect(boolean: Boolean) { + ocFileListDelegate.isMultiSelect = boolean + } + + 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) @@ -323,24 +340,36 @@ class GalleryAdapter( columns = newColumn } - @SuppressLint("NotifyDataSetChanged") fun markAsFavorite(remotePath: String, favorite: Boolean) { val allFiles = getAllFiles() - for (file in allFiles) { - if (file.remotePath == remotePath) { - file.isFavorite = favorite - break - } - } - - Handler(Looper.getMainLooper()).post { + allFiles.firstOrNull { it.remotePath == remotePath }?.also { file -> + file.isFavorite = favorite files = allFiles.toGalleryItems() - notifyDataSetChanged() + notifyItemChanged(file) } } - private fun List.toGalleryItems(): List = this - .groupBy { firstOfMonth(it.modificationTimestamp) } - .map { GalleryItems(it.key, transformToRows(it.value)) } - .sortedBy { it.date }.reversed() + 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 } 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 index 5740936..867c8a3 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java @@ -9,6 +9,7 @@ */ package com.owncloud.android.ui.adapter; +import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; @@ -23,6 +24,7 @@ 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; @@ -34,8 +36,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils; import java.io.File; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -69,8 +69,10 @@ public class LocalFileListAdapter extends RecyclerView.Adapter(); this.viewThemeUtils = viewThemeUtils; this.isWithinEncryptedFolder = isWithinEncryptedFolder; - - swapDirectory(directory); + setHasStableIds(true); } - @Override - public int getItemCount() { - return mFiles.size() + 1; - } - public int getFilesCount() { return mFiles.size(); } @@ -129,30 +125,39 @@ public class LocalFileListAdapter extends RecyclerView.Adapter result = listFilesRecursive(checkedFiles); + List result = FileHelper.INSTANCE.listFilesRecursive(checkedFiles); Log_OC.d(TAG, "Returning " + result.size() + " selected files"); return result.toArray(new String[0]); } - public List listFilesRecursive(Collection files) { - List result = new ArrayList<>(); - - for (File file : files) { - if (file.isDirectory()) { - result.addAll(listFilesRecursive(getFiles(file))); - } else { - result.add(file.getAbsolutePath()); - } - } - - return result; + @Override + public int getItemCount() { + return mFiles.size() + 1; } @Override public long getItemId(int position) { - return mFiles.size() <= position ? position : -1; + if (position >= mFiles.size()) { + return RecyclerView.NO_ID; + } + + File file = mFiles.get(position); + return file.getAbsolutePath().hashCode(); + } + + @Override + public int getItemViewType(int position) { + if (position == mFiles.size()) { + return VIEWTYPE_FOOTER; + } else { + if (MimeTypeUtil.isImageOrVideo(getItem(position))) { + return VIEWTYPE_IMAGE; + } else { + return VIEWTYPE_ITEM; + } + } } @Override @@ -271,19 +276,6 @@ public class LocalFileListAdapter extends RecyclerView.Adapter { - List fileList; - if (directory == null) { - fileList = new ArrayList<>(); - } else { - if (mLocalFolderPicker) { - fileList = getFolders(directory); - } else { - fileList = getFiles(directory); - } + // Load first page of folders + List firstPage = FileHelper.INSTANCE.listDirectoryEntries(directory, currentOffset, PAGE_SIZE, true); + + if (!firstPage.isEmpty()) { + firstPage = sortAndFilterHiddenEntries(firstPage); } - if (!fileList.isEmpty()) { - FileSortOrder sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.localFileListView); - fileList = sortOrder.sortLocalFiles(fileList); + currentOffset += PAGE_SIZE; + updateUIForFirstPage(firstPage); - // Fetch preferences for showing hidden files - boolean showHiddenFiles = preferences.isShowHiddenFilesEnabled(); - if (!showHiddenFiles) { - fileList = filterHiddenFiles(fileList); - } - } - final List newFiles = fileList; + // Load remaining folders, then all files + loadRemainingEntries(directory, true); - uiHandler.post(() -> { - mFiles = newFiles; - mFilesAll = new ArrayList<>(); - mFilesAll.addAll(mFiles); + // Reset for files + currentOffset = 0; - notifyDataSetChanged(); - localFileListFragmentInterface.setLoading(false); - }); + loadRemainingEntries(directory, false); }); - } + @SuppressLint("NotifyDataSetChanged") + private void updateUIForFirstPage(List firstPage) { + new Handler(Looper.getMainLooper()).post(() -> { + mFiles = new ArrayList<>(firstPage); + mFilesAll = new ArrayList<>(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 from = mFiles.size(); + int to = updatedList.size(); + + mFiles.addAll(updatedList); + mFilesAll.addAll(updatedList); + + Log_OC.d(TAG, "notifyItemRange, item size: " + mFilesAll.size()); + + notifyItemRangeInserted(from, to); + }); + } + + @SuppressLint("NotifyDataSetChanged") public void setSortOrder(FileSortOrder sortOrder) { localFileListFragmentInterface.setLoading(true); final Handler uiHandler = new Handler(Looper.getMainLooper()); @@ -366,30 +396,9 @@ public class LocalFileListAdapter extends RecyclerView.Adapter getFolders(final File directory) { - File[] folders = directory.listFiles(File::isDirectory); - - if (folders != null && folders.length > 0) { - return new ArrayList<>(Arrays.asList(folders)); - } else { - return new ArrayList<>(); - } - } - - private List getFiles(File directory) { - File[] files = directory.listFiles(); - - if (files != null && files.length > 0) { - return new ArrayList<>(Arrays.asList(files)); - } else { - return new ArrayList<>(); - } } + @SuppressLint("NotifyDataSetChanged") public void filter(String text) { if (text.isEmpty()) { mFiles = mFilesAll; @@ -514,11 +523,11 @@ public class LocalFileListAdapter extends RecyclerView.Adapter newFiles) { mFiles = newFiles; - mFilesAll = new ArrayList<>(); - mFilesAll.addAll(mFiles); + mFilesAll = new ArrayList<>(mFiles); notifyDataSetChanged(); localFileListFragmentInterface.setLoading(false); diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/NotificationListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/NotificationListAdapter.java index da5da10..67ca8c6 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/NotificationListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/NotificationListAdapter.java @@ -10,6 +10,7 @@ */ package com.owncloud.android.ui.adapter; +import android.annotation.SuppressLint; import android.content.Intent; import android.content.res.Resources; import android.graphics.Typeface; @@ -75,6 +76,7 @@ public class NotificationListAdapter extends RecyclerView.Adapter notificationItems) { notificationsList.clear(); notificationsList.addAll(notificationItems); @@ -340,6 +342,7 @@ public class NotificationListAdapter extends RecyclerView.Adapter Long.compare(o2.getFirstShareTimestamp(), o1.getFirstShareTimestamp())); } else { - mFiles = sortOrder.sortCloudFiles(mFiles); + boolean foldersBeforeFiles = preferences.isSortFoldersBeforeFiles(); + boolean favoritesFirst = preferences.isSortFavoritesFirst(); + mFiles = sortOrder.sortCloudFiles(mFiles, foldersBeforeFiles, favoritesFirst); } new Handler(Looper.getMainLooper()).post(() -> { @@ -505,6 +520,9 @@ public class OCFileListAdapter extends RecyclerView.Adapter() { private val gridTotal = gridWidth * 2 @@ -59,6 +67,14 @@ class SyncedFolderAdapter( private var hideItems = true private val thumbnailThreadPool: Executor = Executors.newCachedThreadPool() + private val minimumSizeForTouchableArea + by lazy { context.resources.getDimensionPixelSize(R.dimen.minimum_size_for_touchable_area) } + private val screenWidth by lazy { context.resources.displayMetrics.widthPixels } + private val standardDoubleMargin + by lazy { context.resources.getDimensionPixelSize(R.dimen.standard_double_margin) } + private val syncedFoldersTitleMargin + by lazy { context.resources.getDimensionPixelSize(R.dimen.synced_folders_title_margin) } + init { shouldShowHeadersForEmptySections(true) shouldShowFooters(true) @@ -234,51 +250,112 @@ class SyncedFolderAdapter( return -1 } + @Suppress("NestedBlockDepth") override fun onBindHeaderViewHolder(commonHolder: SectionedViewHolder, section: Int, expanded: Boolean) { if (section < filteredSyncFolderItems.size) { val holder = commonHolder as HeaderViewHolder - holder.binding.headerContainer.visibility = View.VISIBLE - holder.binding.title.text = filteredSyncFolderItems[section].folderName + holder.binding.run { + headerContainer.visibility = View.VISIBLE - if (MediaFolderType.VIDEO == filteredSyncFolderItems[section].type) { - holder.binding.type.setImageResource(R.drawable.video_32dp) - } else if (MediaFolderType.IMAGE == filteredSyncFolderItems[section].type) { - holder.binding.type.setImageResource(R.drawable.image_32dp) - } else { - holder.binding.type.setImageResource(R.drawable.folder_star_32dp) - } - - holder.binding.syncStatusButton.visibility = View.VISIBLE - holder.binding.syncStatusButton.tag = section - holder.binding.syncStatusButton.setOnClickListener { - filteredSyncFolderItems[section].setEnabled( - !filteredSyncFolderItems[section].isEnabled, - clock.currentTime - ) - setSyncButtonActiveIcon( - holder.binding.syncStatusButton, - filteredSyncFolderItems[section].isEnabled - ) - clickListener.onSyncStatusToggleClick(section, filteredSyncFolderItems[section]) - } - setSyncButtonActiveIcon(holder.binding.syncStatusButton, filteredSyncFolderItems[section].isEnabled) - - if (light) { - holder.binding.settingsButton.visibility = View.GONE - } else { - holder.binding.settingsButton.visibility = View.VISIBLE - holder.binding.settingsButton.tag = section - holder.binding.settingsButton.setOnClickListener { v: View -> - onOverflowIconClicked( - section, - filteredSyncFolderItems[section], - v - ) + if (section == 0) { + autoUploadBatterySaverWarningCard.root.run { + setVisibleIf(powerManagementService.isPowerSavingEnabled) + viewThemeUtils.material.themeCardView(this) + } } - } - initSubFolderWarningButton(holder, section) + val syncedFolder = filteredSyncFolderItems[section] + + title.text = syncedFolder.folderName + + if (MediaFolderType.VIDEO == syncedFolder.type) { + type.setImageResource(R.drawable.video_32dp) + } else if (MediaFolderType.IMAGE == syncedFolder.type) { + type.setImageResource(R.drawable.image_32dp) + } else { + type.setImageResource(R.drawable.folder_star_32dp) + } + + syncStatusButton.visibility = View.VISIBLE + syncStatusButton.tag = section + syncStatusButton.setOnClickListener { + syncedFolder.setEnabled( + !syncedFolder.isEnabled, + clock.currentTime + ) + setSyncButtonActiveIcon( + syncStatusButton, + syncedFolder.isEnabled + ) + clickListener.onSyncStatusToggleClick(section, syncedFolder) + } + setSyncButtonActiveIcon(syncStatusButton, syncedFolder.isEnabled) + + if (light) { + settingsButton.visibility = View.GONE + } else { + settingsButton.visibility = View.VISIBLE + settingsButton.tag = section + settingsButton.setOnClickListener { v: View -> + onOverflowIconClicked( + section, + syncedFolder, + v + ) + } + } + + initSubFolderWarningButton(holder, section) + initNextScanIndicator(holder, syncedFolder) + } + } + } + + private fun initNextScanIndicator(holder: HeaderViewHolder, syncedFolder: SyncedFolder) { + val scanIndicatorText = getNextScanIndicatorText(syncedFolder) + + holder.binding.scanIndicatorText.setVisibleIf(syncedFolder.isEnabled && (scanIndicatorText != null)) + + if (holder.binding.scanIndicatorText.isVisible) { + setMaxWidthOfScanIndicatorText(holder) + holder.binding.scanIndicatorText.text = scanIndicatorText + } else { + setBottomMarginOfTitle(holder) + } + } + + private fun setMaxWidthOfScanIndicatorText(holder: HeaderViewHolder) { + var visibleTrailingIconCount = 2 + if (holder.binding.subFolderWarningButton.isVisible) { + visibleTrailingIconCount += 1 + } + + val takenTrailingSpace = minimumSizeForTouchableArea * visibleTrailingIconCount + val maxWidthInPxOfScanIndicatorText = (screenWidth - takenTrailingSpace) - standardDoubleMargin + + holder.binding.scanIndicatorText.maxWidth = maxWidthInPxOfScanIndicatorText + } + + private fun setBottomMarginOfTitle(holder: HeaderViewHolder) { + val layoutParams = holder.binding.title.layoutParams as ViewGroup.MarginLayoutParams + layoutParams.bottomMargin = syncedFoldersTitleMargin + holder.binding.title.layoutParams = layoutParams + } + + private fun getNextScanIndicatorText(syncedFolder: SyncedFolder): String? { + val scanInterval = syncedFolder.calculateScanInterval(connectivityService, powerManagementService) + val nextScanInMillis = scanInterval.first - System.currentTimeMillis() + val minutesLeft = TimeUnit.MILLISECONDS + .toMinutes(nextScanInMillis) + .coerceAtLeast(0) + .toInt() + + return if (minutesLeft <= 0) { + null + } else { + val scanIntervalMessageId = scanInterval.second ?: return null + context.getString(scanIntervalMessageId) } } @@ -289,7 +366,6 @@ class SyncedFolderAdapter( holder.binding.subFolderWarningButton.run { setVisibleIf(isGivenLocalPathHasEnabledParent) if (isVisible) { - viewThemeUtils.platform.themeImageButton(this) setOnClickListener { clickListener.showSubFolderWarningDialog() } @@ -448,13 +524,12 @@ class SyncedFolderAdapter( binding.root ) - private fun setSyncButtonActiveIcon(syncStatusButton: ImageButton, enabled: Boolean) { + private fun setSyncButtonActiveIcon(syncStatusButton: MaterialButton, enabled: Boolean) { if (enabled) { - syncStatusButton.setImageDrawable( + syncStatusButton.icon = viewThemeUtils.platform.tintDrawable(context, R.drawable.ic_cloud_sync_on, ColorRole.PRIMARY) - ) } else { - syncStatusButton.setImageResource(R.drawable.ic_cloud_sync_off) + syncStatusButton.icon = ContextCompat.getDrawable(context, R.drawable.ic_cloud_sync_off) } } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java index 0393b5a..f382668 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java @@ -90,6 +90,7 @@ public class TrashbinListAdapter extends RecyclerView.Adapter trashbinFiles, boolean clear) { if (clear) { files.clear(); @@ -206,6 +207,7 @@ public class TrashbinListAdapter extends RecyclerView.Adapter + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter + +import android.content.Context +import com.afollestad.sectionedrecyclerview.SectionedViewHolder +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.User +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.databinding.UnifiedSearchCurrentDirectoryItemBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.ui.interfaces.UnifiedSearchCurrentDirItemAction +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.theme.ViewThemeUtils + +@Suppress("LongParameterList") +class UnifiedSearchCurrentDirItemViewHolder( + val binding: UnifiedSearchCurrentDirectoryItemBinding, + val context: Context, + private val viewThemeUtils: ViewThemeUtils, + private val storageManager: FileDataStorageManager, + private val isRTL: Boolean, + private val user: User, + private val appPreferences: AppPreferences, + private val syncedFolderProvider: SyncedFolderProvider, + private val action: UnifiedSearchCurrentDirItemAction +) : SectionedViewHolder(binding.unifiedSearchCurrentDirItemLayout) { + + fun bind(file: OCFile) { + val filenameWithExtension = storageManager.getFilenameConsideringOfflineOperation(file) + val isFolder = file.isFolder + val (filename, extension) = FileStorageUtils.getFilenameAndExtension(filenameWithExtension, isFolder, isRTL) + binding.extension.setVisibleIf(!isFolder) + binding.extension.text = extension + binding.filename.text = filename + viewThemeUtils.platform.colorImageView(binding.thumbnail, ColorRole.PRIMARY) + DisplayUtils.setThumbnail( + file, + binding.thumbnail, + user, + storageManager, + listOf(), + false, + context, + binding.thumbnailShimmer, + appPreferences, + viewThemeUtils, + syncedFolderProvider + ) + + binding.more.setOnClickListener { + action.openFile(file.decryptedRemotePath, true) + } + + binding.unifiedSearchCurrentDirItemLayout.setOnClickListener { + action.openFile(file.decryptedRemotePath, false) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt index 69f43d2..fcc8f46 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt @@ -8,12 +8,10 @@ package com.owncloud.android.ui.adapter import android.content.Context -import android.graphics.drawable.Drawable import android.view.View -import androidx.core.content.res.ResourcesCompat import com.afollestad.sectionedrecyclerview.SectionedViewHolder import com.nextcloud.android.common.ui.theme.utils.ColorRole -import com.nextcloud.client.account.User +import com.nextcloud.common.NextcloudClient import com.nextcloud.model.SearchResultEntryType import com.nextcloud.utils.CalendarEventManager import com.nextcloud.utils.ContactManager @@ -21,21 +19,19 @@ import com.nextcloud.utils.GlideHelper import com.nextcloud.utils.extensions.getType import com.owncloud.android.databinding.UnifiedSearchItemBinding import com.owncloud.android.datamodel.FileDataStorageManager -import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.SearchResultEntry import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface -import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils @Suppress("LongParameterList") class UnifiedSearchItemViewHolder( private val supportsOpeningCalendarContactsLocally: Boolean, val binding: UnifiedSearchItemBinding, - private val user: User, private val storageManager: FileDataStorageManager, private val listInterface: UnifiedSearchListInterface, private val filesAction: FilesAction, val context: Context, + private val nextcloudClient: NextcloudClient, private val viewThemeUtils: ViewThemeUtils ) : SectionedViewHolder(binding.root) { @@ -56,12 +52,8 @@ class UnifiedSearchItemViewHolder( binding.localFileIndicator.visibility = View.GONE } - val mimetype = MimeTypeUtil.getBestMimeTypeByFilename(entry.title) - val entryType = entry.getType() - val placeholder = getPlaceholder(entry, entryType, mimetype) - val nextcloudClient = - OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(user.toOwnCloudAccount(), context) + viewThemeUtils.platform.colorImageView(binding.thumbnail, ColorRole.PRIMARY) GlideHelper.loadIntoImageView( context, nextcloudClient, @@ -104,18 +96,4 @@ class UnifiedSearchItemViewHolder( listInterface.onSearchResultClicked(entry) } } - - private fun getPlaceholder( - entry: SearchResultEntry, - entryType: SearchResultEntryType, - mimetype: String? - ): Drawable { - val iconId = entryType.run { - iconId() - } - - val defaultDrawable = MimeTypeUtil.getFileTypeIcon(mimetype, entry.title, context, viewThemeUtils) - val drawable: Drawable = ResourcesCompat.getDrawable(context.resources, iconId, null) ?: defaultDrawable - return viewThemeUtils.platform.tintDrawable(context, drawable, ColorRole.PRIMARY) - } } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt index 6ad1ba0..baf0e9e 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt @@ -16,15 +16,22 @@ import androidx.core.view.isVisible import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter import com.afollestad.sectionedrecyclerview.SectionedViewHolder import com.nextcloud.client.account.User +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.common.NextcloudClient import com.owncloud.android.R +import com.owncloud.android.databinding.UnifiedSearchCurrentDirectoryItemBinding import com.owncloud.android.databinding.UnifiedSearchEmptyBinding import com.owncloud.android.databinding.UnifiedSearchFooterBinding import com.owncloud.android.databinding.UnifiedSearchHeaderBinding import com.owncloud.android.databinding.UnifiedSearchItemBinding 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.ui.interfaces.UnifiedSearchCurrentDirItemAction import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface import com.owncloud.android.ui.unifiedsearch.UnifiedSearchSection +import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.theme.ViewThemeUtils /** @@ -38,12 +45,18 @@ class UnifiedSearchListAdapter( private val filesAction: UnifiedSearchItemViewHolder.FilesAction, private val user: User, private val context: Context, - private val viewThemeUtils: ViewThemeUtils + private val viewThemeUtils: ViewThemeUtils, + private val appPreferences: AppPreferences, + private val syncedFolderProvider: SyncedFolderProvider, + private val nextcloudClient: NextcloudClient, + private val currentDirItemAction: UnifiedSearchCurrentDirItemAction ) : SectionedRecyclerViewAdapter() { companion object { private const val VIEW_TYPE_EMPTY = Int.MAX_VALUE + private const val VIEW_TYPE_CURRENT_DIR = 0 } + private var currentDirItems: List = listOf() private var sections: List = emptyList() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionedViewHolder { @@ -74,14 +87,29 @@ class UnifiedSearchListAdapter( UnifiedSearchItemViewHolder( supportsOpeningCalendarContactsLocally, binding, - user, storageManager, listInterface, filesAction, context, + nextcloudClient, viewThemeUtils ) } + VIEW_TYPE_CURRENT_DIR -> { + val isRTL = DisplayUtils.isRTL() + val binding = UnifiedSearchCurrentDirectoryItemBinding.inflate(layoutInflater, parent, false) + UnifiedSearchCurrentDirItemViewHolder( + binding, + context, + viewThemeUtils, + storageManager, + isRTL, + user, + appPreferences, + syncedFolderProvider, + currentDirItemAction + ) + } VIEW_TYPE_EMPTY -> { val binding = UnifiedSearchEmptyBinding.inflate(layoutInflater, parent, false) EmptyViewHolder(binding) @@ -90,29 +118,67 @@ class UnifiedSearchListAdapter( } } + private fun isCurrentDirItem(section: Int): Boolean = (currentDirItems.isNotEmpty() && section == 0) + + private fun getSectionIndex(section: Int): Int = if (currentDirItems.isNotEmpty()) section - 1 else section + internal class EmptyViewHolder(binding: UnifiedSearchEmptyBinding) : SectionedViewHolder(binding.getRoot()) - override fun getSectionCount(): Int = sections.size + override fun getSectionCount(): Int = (if (currentDirItems.isNotEmpty()) 1 else 0) + sections.size - override fun getItemCount(section: Int): Int = sections[section].entries.size - - override fun onBindHeaderViewHolder(holder: SectionedViewHolder, section: Int, expanded: Boolean) { - (holder as UnifiedSearchHeaderViewHolder).run { - bind(sections[section]) + override fun getItemViewType(section: Int, relativePosition: Int, absolutePosition: Int): Int = + if (isCurrentDirItem(section)) { + VIEW_TYPE_CURRENT_DIR + } else { + VIEW_TYPE_ITEM } + + override fun getItemCount(section: Int): Int = if (isCurrentDirItem(section)) { + currentDirItems.size + } else { + val index = if (currentDirItems.isNotEmpty()) section - 1 else section + sections.getOrNull(index)?.entries?.size ?: 0 } - override fun onBindFooterViewHolder(holder: SectionedViewHolder, section: Int) { - if (sections[section].hasMoreResults) { - (holder as UnifiedSearchFooterViewHolder).run { - bind(sections[section]) + override fun onBindHeaderViewHolder(holder: SectionedViewHolder, section: Int, expanded: Boolean) { + if (holder is UnifiedSearchHeaderViewHolder) { + if (isCurrentDirItem(section)) { + val name = context.getString(R.string.unified_search_fragment_search_in_this_folder) + val currentDirUnifiedSearchSection = UnifiedSearchSection("", name, listOf(), false) + holder.bind(currentDirUnifiedSearchSection) + } else { + val index = getSectionIndex(section) + val sectionData = sections.getOrNull(index) ?: return + holder.bind(sectionData) } } } - override fun getFooterViewType(section: Int): Int = when { - sections[section].hasMoreResults -> VIEW_TYPE_FOOTER - else -> VIEW_TYPE_EMPTY + override fun onBindFooterViewHolder(holder: SectionedViewHolder, section: Int) { + if (isCurrentDirItem(section)) { + return + } + + val index = getSectionIndex(section) + val sectionData = sections.getOrNull(index) ?: return + + if (sectionData.hasMoreResults && holder is UnifiedSearchFooterViewHolder) { + holder.bind(sectionData) + } + } + + override fun getFooterViewType(section: Int): Int { + if (isCurrentDirItem(section)) { + return VIEW_TYPE_EMPTY + } + + val index = getSectionIndex(section) + val sectionData = sections.getOrNull(index) + + return when { + sectionData?.hasMoreResults == true -> VIEW_TYPE_FOOTER + else -> VIEW_TYPE_EMPTY + } } override fun onBindViewHolder( @@ -121,10 +187,13 @@ class UnifiedSearchListAdapter( relativePosition: Int, absolutePosition: Int ) { - // TODO different binding (and also maybe diff UI) for non-file results - (holder as UnifiedSearchItemViewHolder).run { - val entry = sections[section].entries[relativePosition] - bind(entry) + if (isCurrentDirItem(section) && holder is UnifiedSearchCurrentDirItemViewHolder) { + val entry = currentDirItems.getOrNull(relativePosition) ?: return + holder.bind(entry) + } else if (holder is UnifiedSearchItemViewHolder) { + val index = getSectionIndex(section) + val entry = sections.getOrNull(index)?.entries?.getOrNull(relativePosition) ?: return + holder.bind(entry) } } @@ -145,6 +214,14 @@ class UnifiedSearchListAdapter( notifyDataSetChanged() } + @SuppressLint("NotifyDataSetChanged") + fun setDataCurrentDirItems(currentDirItems: List) { + this.currentDirItems = currentDirItems + notifyDataSetChanged() + } + + fun isCurrentDirItemsEmpty(): Boolean = currentDirItems.isEmpty() + init { // initialise thumbnails cache on background thread ThumbnailsCacheManager.initDiskCacheAsync() diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/UploadListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/UploadListAdapter.java index 4dee8b3..5f30c45 100755 --- a/app/src/main/java/com/owncloud/android/ui/adapter/UploadListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/UploadListAdapter.java @@ -8,6 +8,7 @@ */ package com.owncloud.android.ui.adapter; +import android.annotation.SuppressLint; import android.app.NotificationManager; import android.content.ActivityNotFoundException; import android.content.Context; @@ -30,6 +31,7 @@ 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.ConnectivityService; +import com.nextcloud.utils.extensions.ViewExtensionsKt; import com.owncloud.android.MainApp; import com.owncloud.android.R; import com.owncloud.android.databinding.UploadListHeaderBinding; @@ -42,6 +44,7 @@ import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus; import com.owncloud.android.db.OCUpload; import com.owncloud.android.db.OCUploadComparator; import com.owncloud.android.db.UploadResult; +import com.owncloud.android.files.services.NameCollisionPolicy; import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.operations.RefreshFolderOperation; @@ -58,6 +61,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils; import java.io.File; import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.concurrent.LinkedBlockingQueue; @@ -66,6 +70,7 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import androidx.annotation.NonNull; +import kotlin.Unit; /** * This Adapter populates a ListView with following types of uploads: pending, active, completed. Filtering possible. @@ -73,6 +78,30 @@ import androidx.annotation.NonNull; public class UploadListAdapter extends SectionedRecyclerViewAdapter { private static final String TAG = UploadListAdapter.class.getSimpleName(); + private static class GroupConfig { + final Type type; + final int titleRes; + final UploadStatus status; + final NameCollisionPolicy nameCollisionPolicy; + + GroupConfig(Type type, int titleRes, UploadStatus status, NameCollisionPolicy nameCollisionPolicy) { + this.type = type; + this.titleRes = titleRes; + this.status = status; + this.nameCollisionPolicy = nameCollisionPolicy; + } + + public static List getConfigs() { + return List.of( + new GroupConfig(Type.CURRENT, R.string.uploads_view_group_current_uploads, UploadStatus.UPLOAD_IN_PROGRESS, null), + new GroupConfig(Type.FAILED, R.string.uploads_view_group_failed_uploads, UploadStatus.UPLOAD_FAILED, null), + new GroupConfig(Type.CANCELLED, R.string.uploads_view_group_manually_cancelled_uploads, UploadStatus.UPLOAD_CANCELLED, null), + new GroupConfig(Type.FINISHED, R.string.uploads_view_group_finished_uploads, UploadStatus.UPLOAD_SUCCEEDED, NameCollisionPolicy.ASK_USER), // ASK_USER default value + new GroupConfig(Type.SKIPPED, R.string.uploads_view_upload_status_skip, UploadStatus.UPLOAD_SUCCEEDED, NameCollisionPolicy.SKIP) + ); + } + } + private UploadProgressListener uploadProgressListener; private final FileActivity parentActivity; private final UploadsStorageManager uploadsStorageManager; @@ -90,8 +119,63 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter(1), new UploadGroupLoadPolicy()); + private final List uploadGroupConfigs = GroupConfig.getConfigs(); + private final FileUploadHelper uploadHelper = FileUploadHelper.Companion.instance(); + public UploadListAdapter(final FileActivity fileActivity, + final UploadsStorageManager uploadsStorageManager, + final FileDataStorageManager storageManager, + final UserAccountManager accountManager, + final ConnectivityService connectivityService, + final PowerManagementService powerManagementService, + final Clock clock, + final ViewThemeUtils viewThemeUtils) { + Log_OC.d(TAG, "UploadListAdapter"); + + this.parentActivity = fileActivity; + this.uploadsStorageManager = uploadsStorageManager; + this.storageManager = storageManager; + this.accountManager = accountManager; + this.connectivityService = connectivityService; + this.powerManagementService = powerManagementService; + this.clock = clock; + this.viewThemeUtils = viewThemeUtils; + + uploadGroups = new UploadGroup[uploadGroupConfigs.size()]; + + shouldShowHeadersForEmptySections(false); + initUploadGroups(); + showUser = accountManager.getAccounts().length > 1; + } + + private void initUploadGroups() { + final var optionalUser = parentActivity.getUser(); + if (optionalUser.isEmpty()) { + return; + } + + final var accountName = optionalUser.get().getAccountName(); + + for (int i = 0; i < uploadGroupConfigs.size(); i++) { + final var config = uploadGroupConfigs.get(i); + uploadGroups[i] = createUploadGroup(config, accountName); + } + } + + private UploadGroup createUploadGroup(GroupConfig config, String accountName) { + return new UploadGroup(config.type, parentActivity.getString(config.titleRes)) { + @Override + public void refresh(LoadCompleteListener listener) { + uploadHelper.getUploadsByStatus(accountName, config.status, config.nameCollisionPolicy,ocUploads -> { + fixAndSortItems(ocUploads); + listener.onComplete(); + return Unit.INSTANCE; + }); + } + }; + } + @Override public int getSectionCount() { return uploadGroups.length; @@ -120,6 +204,13 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter {{ + toggleSectionExpanded(section); + headerViewHolder.binding.uploadListState.setImageResource(isSectionExpanded(section) ? + R.drawable.ic_expand_less : + R.drawable.ic_expand_more); + }}); + switch (group.type) { case CURRENT, FINISHED -> headerViewHolder.binding.uploadListAction.setImageResource(R.drawable.ic_close); case CANCELLED, FAILED -> @@ -127,9 +218,12 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter { switch (group.type) { - case CURRENT -> new Thread(() -> { + case CURRENT -> { OCUpload ocUpload = group.getItem(0); if (ocUpload == null) { return; @@ -140,18 +234,20 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter Unit.INSTANCE); + } loadUploadItemsFromDb(); - }).start(); + } case FINISHED -> { uploadsStorageManager.clearSuccessfulUploads(); loadUploadItemsFromDb(); } - case FAILED -> { - showFailedPopupMenu(headerViewHolder); - } - case CANCELLED -> { - showCancelledPopupMenu(headerViewHolder); + case FAILED -> showFailedPopupMenu(headerViewHolder); + case CANCELLED -> showCancelledPopupMenu(headerViewHolder); + default -> { + } } }); @@ -168,16 +264,12 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter { - uploadHelper.retryFailedUploads( - uploadsStorageManager, - connectivityService, - accountManager, - powerManagementService); - loadUploadItemsFromDb(); - }).start(); + uploadHelper.retryFailedUploads( + uploadsStorageManager, + connectivityService, + accountManager, + powerManagementService); + loadUploadItemsFromDb(); } return true; @@ -238,66 +330,6 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter 1; - } - - @Override public void onBindViewHolder(SectionedViewHolder holder, int section, int relativePosition, int absolutePosition) { if (uploadGroups.length == 0 || section < 0 || section >= uploadGroups.length) { @@ -423,7 +455,8 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter { - uploadHelper.cancelFileUpload(item.getRemotePath(), item.getAccountName()); + uploadHelper.updateUploadStatus(item.getRemotePath(), item.getAccountName(), UploadStatus.UPLOAD_CANCELLED); + FileUploadWorker.Companion.cancelCurrentUpload(item.getRemotePath(), item.getAccountName(), () -> Unit.INSTANCE); loadUploadItemsFromDb(); }); @@ -727,40 +760,48 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter { - status = parentActivity.getString(R.string.uploads_view_later_waiting_to_upload); - if (uploadHelper.isUploadingNow(upload)) { - // really uploading, bind the progress bar to listen for progress updates - status = parentActivity.getString(R.string.uploader_upload_in_progress_ticker); - } - if (parentActivity.getAppPreferences().isGlobalUploadPaused()) { - status = parentActivity.getString(R.string.upload_global_pause_title); - } - } - case UPLOAD_SUCCEEDED -> { - if (upload.getLastResult() == UploadResult.SAME_FILE_CONFLICT) { - status = parentActivity.getString(R.string.uploads_view_upload_status_succeeded_same_file); - } else if (upload.getLastResult() == UploadResult.FILE_NOT_FOUND) { - status = getUploadFailedStatusText(upload.getLastResult()); + if (prefs.isGlobalUploadPaused()) { + status = statusRes.getString(R.string.upload_global_pause_title); + } else if (uploadHelper.isUploadingNow(upload)) { + status = statusRes.getString(R.string.uploader_upload_in_progress_ticker); } else { - status = parentActivity.getString(R.string.uploads_view_upload_status_succeeded); + status = statusRes.getString(R.string.uploads_view_later_waiting_to_upload); } } - case UPLOAD_FAILED -> { - status = getUploadFailedStatusText(upload.getLastResult()); - } - case UPLOAD_CANCELLED -> { - status = parentActivity.getString(R.string.upload_manually_cancelled); - } - default -> { - status = "Uncontrolled status: " + upload.getUploadStatus(); + case UPLOAD_SUCCEEDED -> { + UploadResult result = upload.getLastResult(); + if (result == UploadResult.SAME_FILE_CONFLICT) { + status = statusRes.getString(R.string.uploads_view_upload_status_succeeded_same_file); + } else if (result == UploadResult.FILE_NOT_FOUND) { + status = getUploadFailedStatusText(result); + } else if (upload.getNameCollisionPolicy() == NameCollisionPolicy.SKIP) { + status = statusRes.getString(R.string.uploads_view_upload_status_skip_reason); + } else { + status = statusRes.getString(R.string.uploads_view_upload_status_succeeded); + } } + + case UPLOAD_FAILED -> + status = getUploadFailedStatusText(upload.getLastResult()); + + case UPLOAD_CANCELLED -> + status = statusRes.getString(R.string.upload_manually_cancelled); + + default -> + status = "Uncontrolled status: " + uploadStatus; } + return status; } + @NonNull private String getUploadFailedStatusText(UploadResult result) { return switch (result) { @@ -910,7 +951,7 @@ public class UploadListAdapter extends SectionedRecyclerViewAdapter { + synchronized (completedCount) { + group.apply(); + completedCount[0]++; + if (completedCount[0] == groupCount) { + + // All groups finished, update UI once + parentActivity.runOnUiThread(() -> { + notifyDataSetChanged(); + for (LoadCompleteListener loadCompleteListener : loadCompleteListenerSet) { + loadCompleteListener.onComplete(); + } + }); + } + } + }); } - parentActivity.runOnUiThread(() -> { - for (UploadGroup uploadGroup : uploadGroups) { - uploadGroup.apply(); - } - notifyDataSetChanged(); - for (LoadCompleteListener loadCompleteListener : loadCompleteListenerSet) { - loadCompleteListener.onComplete(); - } - }); } } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/UserListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/UserListAdapter.java index 1ad6097..6c61b21 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/UserListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/UserListAdapter.java @@ -12,6 +12,7 @@ */ package com.owncloud.android.ui.adapter; +import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Paint; import android.graphics.drawable.Drawable; @@ -162,6 +163,7 @@ public class UserListAdapter extends RecyclerView.Adapter items){ if(values == null){ values = new ArrayList<>(); diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/FetchRemoteFileTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/FetchRemoteFileTask.java index 79b2f3a..a26bb6b 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/FetchRemoteFileTask.java +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/FetchRemoteFileTask.java @@ -8,6 +8,7 @@ */ package com.owncloud.android.ui.asynctasks; +import android.annotation.SuppressLint; import android.os.AsyncTask; import com.nextcloud.client.account.User; @@ -30,7 +31,7 @@ public class FetchRemoteFileTask extends AsyncTask { private final User user; private final String fileId; private final FileDataStorageManager storageManager; - private final FileDisplayActivity fileDisplayActivity; + @SuppressLint("StaticFieldLeak") private final FileDisplayActivity fileDisplayActivity; private OCFile ocFile; public FetchRemoteFileTask(User user, diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/NotificationExecuteActionTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/NotificationExecuteActionTask.java index 1e3d15d..d8e92df 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/NotificationExecuteActionTask.java +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/NotificationExecuteActionTask.java @@ -6,6 +6,7 @@ */ package com.owncloud.android.ui.asynctasks; +import android.annotation.SuppressLint; import android.os.AsyncTask; import com.nextcloud.common.NextcloudClient; @@ -32,7 +33,7 @@ public class NotificationExecuteActionTask extends AsyncTask { @Override protected Boolean doInBackground(Void... voids) { + if (file == null) return Boolean.FALSE; + HttpClient client = new HttpClient(); GetMethod getMethod = null; - FileOutputStream fos = null; try { getMethod = new GetMethod(url); int status = client.executeMethod(getMethod); - if (status == HttpStatus.SC_OK) { - if (file.exists() && !file.delete()) { - return Boolean.FALSE; - } + if (status != HttpStatus.SC_OK) return Boolean.FALSE; - file.getParentFile().mkdirs(); + if (file.exists() && !file.delete()) return Boolean.FALSE; - if (!file.getParentFile().exists()) { - Log_OC.d(TAG, file.getParentFile().getAbsolutePath() + " does not exist"); - return Boolean.FALSE; - } + File parentFile = file.getParentFile(); + if (parentFile == null) return Boolean.FALSE; - if (!file.createNewFile()) { - Log_OC.d(TAG, file.getAbsolutePath() + " could not be created"); - return Boolean.FALSE; - } + Files.createDirectories(parentFile.toPath()); + if (!parentFile.exists()) { + Log_OC.d(TAG, parentFile.getAbsolutePath() + " does not exist"); + return Boolean.FALSE; + } + if (!file.createNewFile()) { + Log_OC.d(TAG, file.getAbsolutePath() + " could not be created"); + return Boolean.FALSE; + } + + Header contentLengthHeader = getMethod.getResponseHeader("Content-Length"); + long totalToTransfer = 0; + if (contentLengthHeader != null && !contentLengthHeader.getValue().isEmpty()) { + totalToTransfer = Long.parseLong(contentLengthHeader.getValue()); + } + + try ( BufferedInputStream bis = new BufferedInputStream(getMethod.getResponseBodyAsStream()); - fos = new FileOutputStream(file); + FileOutputStream fos = new FileOutputStream(file) + ) { + byte[] buffer = new byte[4096]; long transferred = 0; + int read; - Header contentLength = getMethod.getResponseHeader("Content-Length"); - long totalToTransfer = contentLength != null && contentLength.getValue().length() > 0 ? - Long.parseLong(contentLength.getValue()) : 0; - - byte[] bytes = new byte[4096]; - int readResult; - while ((readResult = bis.read(bytes)) != -1) { - fos.write(bytes, 0, readResult); - transferred += readResult; + while ((read = bis.read(buffer)) != -1) { + fos.write(buffer, 0, read); + transferred += read; } - // Check if the file is completed - if (transferred != totalToTransfer) { + + if (totalToTransfer > 0 && transferred != totalToTransfer) { + Log_OC.d(TAG, "Transferred bytes (" + transferred + + ") != expected (" + totalToTransfer + ")"); return Boolean.FALSE; } - - if (getMethod.getResponseBodyAsStream() != null) { - getMethod.getResponseBodyAsStream().close(); - } } - } catch (IOException e) { - Log_OC.e(TAG, "Error reading file", e); + + return Boolean.TRUE; + } catch (Exception e) { + Log_OC.e(TAG, "Error downloading file", e); + return Boolean.FALSE; } finally { if (getMethod != null) { getMethod.releaseConnection(); } - if (fos != null) { - try { - fos.close(); - } catch (IOException e) { - Log_OC.e(TAG, "Error closing file output stream", e); - } - } } - - return Boolean.TRUE; } @Override diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt index cde23d6..9c300b5 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt @@ -24,7 +24,6 @@ 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.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener -import com.owncloud.android.ui.preview.PreviewImageActivity import javax.inject.Inject /** @@ -125,14 +124,11 @@ class RemoveFilesDialogFragment : } finishActionMode() - finishPreviewImageActivity() } } override fun onNeutral(callerTag: String?) = Unit - private fun finishPreviewImageActivity() = getTypedActivity(PreviewImageActivity::class.java)?.finish() - private fun setActionMode(actionMode: ActionMode?) { this.actionMode = actionMode } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt index d3372b7..6656d0b 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt @@ -542,29 +542,59 @@ class SyncedFolderPreferencesDialogFragment : } /** - * Get index for name collision selection dialog. + * Converts a [NameCollisionPolicy] enum value into the corresponding + * **UI dialog selection index** used in the dialog. * - * @return 0 if ASK_USER, 1 if OVERWRITE, 2 if RENAME, 3 if SKIP, Otherwise: 0 + * ⚠️ **Important:** + * These dialog indices are **not** the same as the integer values stored + * in the database or defined in [NameCollisionPolicy]. + * This mapping is purely for UI selection purposes. + * + * | Policy | Dialog Index | + * |---------------------|--------------| + * | ASK_USER (default) | 0 | + * | OVERWRITE | 1 | + * | RENAME | 2 | + * | SKIP | 3 | + * + * @param nameCollisionPolicy The collision handling policy. + * @return The index to preselect in the UI dialog for the given policy. */ @Suppress("MagicNumber") private fun getSelectionIndexForNameCollisionPolicy(nameCollisionPolicy: NameCollisionPolicy): Int = when (nameCollisionPolicy) { NameCollisionPolicy.OVERWRITE -> 1 NameCollisionPolicy.RENAME -> 2 - NameCollisionPolicy.CANCEL -> 3 + NameCollisionPolicy.SKIP -> 3 NameCollisionPolicy.ASK_USER -> 0 } /** - * Get index for name collision selection dialog. Inverse of getSelectionIndexForNameCollisionPolicy. + * Converts a **UI dialog selection index** from the dialog + * back into a [NameCollisionPolicy] enum value. * - * @return ASK_USER if 0, OVERWRITE if 1, RENAME if 2, SKIP if 3. Otherwise: ASK_USER + * ⚠️ **Important:** + * These indices are defined only for the dialog and are **not** the same as + * the values used in the database or internal enum ordinals. + * Always use this function to translate the dialog result safely. + * + * | Dialog Index | Policy | + * |---------------|--------------------| + * | 0 | ASK_USER (default) | + * | 1 | OVERWRITE | + * | 2 | RENAME | + * | 3 | SKIP | + * + * Any unexpected index value will default to [NameCollisionPolicy.ASK_USER]. + * + * @param index The selected index from the dialog. + * @return The corresponding [NameCollisionPolicy] value. */ @Suppress("MagicNumber") private fun getNameCollisionPolicyForSelectionIndex(index: Int): NameCollisionPolicy = when (index) { 1 -> NameCollisionPolicy.OVERWRITE 2 -> NameCollisionPolicy.RENAME - 3 -> NameCollisionPolicy.CANCEL + 3 -> NameCollisionPolicy.SKIP 0 -> NameCollisionPolicy.ASK_USER else -> NameCollisionPolicy.ASK_USER } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt index a88da89..54bbad8 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt @@ -25,7 +25,6 @@ import android.os.Handler import android.os.Looper import android.os.Parcelable import android.util.DisplayMetrics -import android.view.KeyEvent import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -57,7 +56,7 @@ import com.nextcloud.client.di.Injectable import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.preferences.AppPreferencesImpl import com.nextcloud.utils.extensions.getTypedActivity -import com.nextcloud.utils.extensions.handleBackButtonEvent +import com.nextcloud.utils.extensions.mainThread import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.ListFragmentBinding @@ -65,7 +64,6 @@ import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.files.SearchRemoteOperation import com.owncloud.android.lib.resources.status.OwnCloudVersion import com.owncloud.android.ui.EmptyRecyclerView -import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.activity.FolderPickerActivity import com.owncloud.android.ui.activity.OnEnforceableRefreshListener @@ -707,6 +705,14 @@ open class ExtendedListFragment : true ) } + EmptyListState.ERROR -> { + setMessageForEmptyList( + R.string.file_list_error_headline, + R.string.file_list_error_description, + R.drawable.ic_no_internet, + false + ) + } else -> { setMessageForEmptyList( R.string.file_list_empty_headline, @@ -715,6 +721,10 @@ open class ExtendedListFragment : true ) } + }.also { + mainThread { + mRefreshListLayout?.isRefreshing = false + } } } @@ -767,18 +777,7 @@ open class ExtendedListFragment : } } - protected fun setupBackButtonRedirectToAllFiles() { - view?.isFocusableInTouchMode = true - view?.requestFocus() - view?.setOnKeyListener { _: View, keyCode: Int, event: KeyEvent -> - if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { - val fda = getTypedActivity(FileActivity::class.java) - val currentDir = fda?.currentDir ?: return@setOnKeyListener false - return@setOnKeyListener fda.handleBackButtonEvent(currentDir) - } - false - } - } + protected fun isAccountManagerInitialized(): Boolean = ::accountManager.isInitialized private data class EmptyListData(val headline: Int, val message: Int, val icon: Int?, val tintIcon: Boolean) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java index 858c02e..2aaa0b4 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java @@ -197,7 +197,7 @@ public class FileDetailFragment extends FileFragment implements OnClickListener, } public void goBackToOCFileListFragment() { - requireActivity().onBackPressed(); + requireActivity().getOnBackPressedDispatcher().onBackPressed(); } @Override @@ -574,7 +574,7 @@ public class FileDetailFragment extends FileFragment implements OnClickListener, // configure UI for depending upon local state of the file if (transferring || (FileDownloadHelper.Companion.instance().isDownloading(user, file)) - || (FileUploadHelper.Companion.instance().isUploading(user, file))) { + || (FileUploadHelper.Companion.instance().isUploading(file.getRemotePath(), user.getAccountName()))) { setButtonsForTransferring(); } else if (file.isDown()) { @@ -736,7 +736,7 @@ public class FileDetailFragment extends FileFragment implements OnClickListener, if (FileDownloadHelper.Companion.instance().isDownloading(user, getFile())) { binding.progressText.setText(R.string.downloader_download_in_progress_ticker); } else { - if (FileUploadHelper.Companion.instance().isUploading(user, getFile())) { + if (FileUploadHelper.Companion.instance().isUploading(getFile().getRemotePath(), user.getAccountName())) { binding.progressText.setText(R.string.uploader_upload_in_progress_ticker); } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java index 672ca0f..094c03d 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java @@ -234,12 +234,6 @@ public class GalleryFragment extends OCFileListFragment implements GalleryFragme } } - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - setupBackButtonRedirectToAllFiles(); - } - @Override public void onMessageEvent(ChangeMenuEvent changeMenuEvent) { super.onMessageEvent(changeMenuEvent); @@ -301,10 +295,8 @@ public class GalleryFragment extends OCFileListFragment implements GalleryFragme @Override public boolean onOptionsItemSelected(MenuItem item) { - // Handle item selection - if (item.getItemId() == R.id.action_three_dot_icon && !this.isPhotoSearchQueryRunning() - && galleryFragmentBottomSheetDialog != null) { + if (item.getItemId() == R.id.action_three_dot_icon && galleryFragmentBottomSheetDialog != null) { showBottomSheet(); return true; } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragmentBottomSheetDialog.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragmentBottomSheetDialog.kt index 05118c0..4b382de 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragmentBottomSheetDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragmentBottomSheetDialog.kt @@ -13,6 +13,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +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.di.Injectable @@ -32,6 +34,11 @@ class GalleryFragmentBottomSheetDialog(private val actions: GalleryFragmentBotto override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentGalleryBottomSheetBinding.inflate(layoutInflater, container, false) + + val bottomSheetDialog = dialog as BottomSheetDialog + bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + bottomSheetDialog.behavior.skipCollapsed = true + return binding.root } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java index 25fa784..f5757c8 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java @@ -112,7 +112,6 @@ public class LocalFileListFragment extends ExtendedListFragment implements super.onActivityCreated(savedInstanceState); mAdapter = new LocalFileListAdapter(mContainerActivity.isFolderPickerMode(), - mContainerActivity.getInitialDirectory(), this, preferences, getActivity(), @@ -256,32 +255,30 @@ public class LocalFileListFragment extends ExtendedListFragment implements * @param directory Directory to be listed */ public void listDirectory(File directory) { - - // Check input parameters for null if (directory == null) { - if (mDirectory != null) { - directory = mDirectory; - } else { - directory = Environment.getExternalStorageDirectory(); - // TODO be careful with the state of the storage; could not be available - if (directory == null) { - return; // no files to show - } - } + directory = (mDirectory != null) ? mDirectory : Environment.getExternalStorageDirectory(); + if (directory == null) return; } - - // if that's not a directory -> List its parent + // If input is not a directory, list its parent if (!directory.isDirectory()) { Log_OC.w(TAG, "You see, that is not a directory -> " + directory); directory = directory.getParentFile(); + if (directory == null) { + Log_OC.w(TAG, "parent directory is null, cannot swap directory"); + return; + } } - // by now, only files in the same directory will be kept as selected mAdapter.removeAllFilesFromCheckedFiles(); mAdapter.swapDirectory(directory); mDirectory = directory; + + final var recyclerView = getRecyclerView(); + if (recyclerView != null) { + recyclerView.scrollToPosition(0); + } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetDialog.kt b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetDialog.kt index 1d79902..d676897 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListBottomSheetDialog.kt @@ -7,6 +7,7 @@ */ package com.owncloud.android.ui.fragment +import android.os.Build import android.os.Bundle import android.view.View import com.google.android.material.bottomsheet.BottomSheetDialog @@ -16,7 +17,9 @@ import com.nextcloud.client.account.User import com.nextcloud.client.device.DeviceInfo import com.nextcloud.client.di.Injectable import com.nextcloud.client.documentscan.AppScanOptionalFeature +import com.nextcloud.utils.BuildHelper.isFlavourGPlay import com.nextcloud.utils.EditorUtils +import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.FileListActionsBottomSheetCreatorBinding import com.owncloud.android.databinding.FileListActionsBottomSheetFragmentBinding @@ -26,6 +29,7 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.DirectEditing import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.PermissionUtil import com.owncloud.android.utils.theme.ThemeUtils import com.owncloud.android.utils.theme.ViewThemeUtils @@ -66,6 +70,20 @@ class OCFileListBottomSheetDialog( createRichWorkspace() setupClickListener() filterActionsForOfflineOperations() + + if (MainApp.isClientBranded() && isFlavourGPlay()) { + // this way we can have branded clients with that permission + val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + PermissionUtil.manifestHasAllFilesPermission(context) + } else { + true + } + + if (!hasPermission) { + binding.menuUploadFiles.visibility = View.GONE + binding.uploadContentFromOtherApps.text = context.getString(R.string.upload_files) + } + } } private fun applyBranding() { @@ -77,6 +95,8 @@ class OCFileListBottomSheetDialog( colorImageView(menuIconScanDocUpload, ColorRole.PRIMARY) colorImageView(menuIconMkdir, ColorRole.PRIMARY) colorImageView(menuIconAddFolderInfo, ColorRole.PRIMARY) + + colorViewBackground(binding.bottomSheet, ColorRole.SURFACE) } } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index ace98a6..2489b4d 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -225,7 +225,7 @@ public class OCFileListFragment extends ExtendedListFragment implements protected SearchType currentSearchType; protected boolean searchFragment; protected SearchEvent searchEvent; - protected AsyncTask remoteOperationAsyncTask; + private OCFileListSearchTask searchTask; protected String mLimitToMimeType; private FloatingActionButton mFabMain; public static boolean isMultipleFileSelectedForCopyOrMove = false; @@ -277,12 +277,6 @@ public class OCFileListFragment extends ExtendedListFragment implements super.onResume(); } - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - setupBackButtonRedirectToAllFiles(); - } - @Override public void onDestroyView() { super.onDestroyView(); @@ -356,8 +350,8 @@ public class OCFileListFragment extends ExtendedListFragment implements setOnRefreshListener(null); mContainerActivity = null; - if (remoteOperationAsyncTask != null) { - remoteOperationAsyncTask.cancel(true); + if (searchTask != null) { + searchTask.cancel(); } super.onDetach(); } @@ -622,7 +616,7 @@ public class OCFileListFragment extends ExtendedListFragment implements return; } - boolean isWithinEncryptedFolder = getCurrentFile().isEncrypted(); + boolean isWithinEncryptedFolder = file.isEncrypted(); UploadFilesActivity.startUploadActivityForResult(fileActivity, user.get(), FileDisplayActivity.REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM, isWithinEncryptedFolder); } @@ -1051,7 +1045,7 @@ public class OCFileListFragment extends ExtendedListFragment implements private Future> getPreviousFile() { CompletableFuture> completableFuture = new CompletableFuture<>(); - Executors.newCachedThreadPool().submit(() -> { + Executors.newCachedThreadPool().execute(() -> { var result = new Pair(null, null); FileDataStorageManager storageManager = mContainerActivity.getStorageManager(); @@ -1067,7 +1061,6 @@ public class OCFileListFragment extends ExtendedListFragment implements completableFuture.complete(result); - return null; }); return completableFuture; @@ -1248,6 +1241,11 @@ public class OCFileListFragment extends ExtendedListFragment implements } private void handlePendingDownloadFile(OCFile file) { + if (!isAccountManagerInitialized()) { + Log_OC.e(TAG, "AccountManager not yet initialized"); + return; + } + User account = accountManager.getUser(); OCCapability capability = mContainerActivity.getStorageManager().getCapability(account.getAccountName()); @@ -1509,6 +1507,7 @@ public class OCFileListFragment extends ExtendedListFragment implements * * @return The currently viewed OCFile */ + @Nullable public OCFile getCurrentFile() { return mFile; } @@ -1908,10 +1907,10 @@ public class OCFileListFragment extends ExtendedListFragment implements return; } - // avoid calling api multiple times if async task is already executing - if (remoteOperationAsyncTask != null && remoteOperationAsyncTask.getStatus() != AsyncTask.Status.FINISHED) { + // avoid calling api multiple times if task is already executing + if (searchTask != null && !searchTask.isFinished()) { if (searchEvent != null) { - Log_OC.d(TAG, "OCFileListSearchAsyncTask already running skipping new api call for search event: " + searchEvent.getSearchType()); + Log_OC.d(TAG, "OCFileListSearchTask already running skipping new api call for search event: " + searchEvent.getSearchType()); } return; @@ -1942,11 +1941,10 @@ public class OCFileListFragment extends ExtendedListFragment implements final User currentUser = accountManager.getUser(); - final RemoteOperation remoteOperation = getSearchRemoteOperation(currentUser, event); + final var remoteOperation = getSearchRemoteOperation(currentUser, event); - remoteOperationAsyncTask = new OCFileListSearchAsyncTask(mContainerActivity, this, remoteOperation, currentUser, event); - - remoteOperationAsyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + searchTask = new OCFileListSearchTask(mContainerActivity, this, remoteOperation, currentUser, event, SharedListFragment.TASK_TIMEOUT); + searchTask.execute(); } @@ -2325,4 +2323,15 @@ public class OCFileListFragment extends ExtendedListFragment implements public SearchEvent getSearchEvent() { return searchEvent; } + + public boolean isSearchEventFavorite() { + if (searchEvent == null) { + return false; + } + return searchEvent.getSearchType() == SearchRemoteOperation.SearchType.FAVORITE_SEARCH; + } + + public boolean shouldNavigateBackToAllFiles() { + return ((this instanceof GalleryFragment) || isSearchEventFavorite() || DrawerActivity.menuItemId == R.id.nav_favorites); + } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListSearchTask.kt b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListSearchTask.kt new file mode 100644 index 0000000..20d545a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListSearchTask.kt @@ -0,0 +1,102 @@ +/* + * 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.fragment + +import android.annotation.SuppressLint +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.account.User +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +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.events.SearchEvent +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.lang.ref.WeakReference + +@Suppress("LongParameterList") +@SuppressLint("NotifyDataSetChanged") +class OCFileListSearchTask( + containerActivity: FileFragment.ContainerActivity, + fragment: OCFileListFragment, + private val remoteOperation: RemoteOperation>, + private val currentUser: User, + private val event: SearchEvent, + private val taskTimeout: Long +) { + companion object { + private const val TAG = "OCFileListSearchTask" + } + + private val activityReference: WeakReference = WeakReference(containerActivity) + private val fragmentReference: WeakReference = WeakReference(fragment) + + private val fileDataStorageManager: FileDataStorageManager? + get() = activityReference.get()?.storageManager + + private var job: Job? = null + + @Suppress("TooGenericExceptionCaught", "DEPRECATION", "ReturnCount") + fun execute() { + Log_OC.d(TAG, "search task running, query: ${event.searchType}") + val fragment = fragmentReference.get() ?: return + val context = fragment.context ?: return + + job = fragment.lifecycleScope.launch { + val result: RemoteOperationResult>? = withContext(Dispatchers.IO) { + try { + withTimeoutOrNull(taskTimeout) { + remoteOperation.execute(currentUser, context) + } ?: remoteOperation.executeNextcloudClient(currentUser, context) + } catch (e: Exception) { + Log_OC.e(TAG, "exception execute: ", e) + null + } + } + + withContext(Dispatchers.Main) { + if (!fragment.isAdded || !fragment.searchFragment) { + Log_OC.e(TAG, "cannot fetch sharees fragment is not ready") + return@withContext + } + + if (result?.isSuccess == true) { + if (result.resultData.isEmpty()) { + fragment.setEmptyListMessage(SearchType.NO_SEARCH) + return@withContext + } + + fragment.searchEvent = event + fragment.adapter.setData( + result.resultData, + fragment.currentSearchType, + fileDataStorageManager, + fragment.mFile, + true + ) + + return@withContext + } + + fragment.activity?.let { + DisplayUtils.showSnackMessage(it, R.string.error_fetching_sharees) + } + } + } + } + + fun cancel() = job?.cancel(null) + + fun isFinished(): Boolean = job?.isCompleted == true +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/SearchType.kt b/app/src/main/java/com/owncloud/android/ui/fragment/SearchType.kt index 36c1e6f..4e27e02 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/SearchType.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/SearchType.kt @@ -32,5 +32,6 @@ enum class EmptyListState : Parcelable { ADD_FOLDER, ONLY_ON_DEVICE, LOCAL_FILE_LIST_EMPTY_FILE, - LOCAL_FILE_LIST_EMPTY_FOLDER + LOCAL_FILE_LIST_EMPTY_FOLDER, + ERROR } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt index 6c86921..c146a57 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.lifecycleScope import com.nextcloud.client.account.User import com.nextcloud.client.di.Injectable import com.nextcloud.client.logger.Logger +import com.nextcloud.common.SessionTimeOut import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.operations.RemoteOperation @@ -68,7 +69,7 @@ class SharedListFragment : } override fun getSearchRemoteOperation(currentUser: User?, event: SearchEvent?): RemoteOperation<*> = - GetSharesRemoteOperation() + GetSharesRemoteOperation(false, SessionTimeOut(TASK_TIMEOUT, TASK_TIMEOUT)) @Suppress("DEPRECATION") private suspend fun fetchFileData(partialFile: OCFile): OCFile? = withContext(Dispatchers.IO) { @@ -185,5 +186,6 @@ class SharedListFragment : companion object { private val SHARED_TAG = SharedListFragment::class.java.simpleName + const val TASK_TIMEOUT = 120_000 } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt index 6089e78..1aa3e15 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.owncloud.android.ui.fragment - import android.Manifest import android.content.Context import android.content.Intent @@ -27,25 +26,33 @@ import androidx.appcompat.widget.SearchView import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.core.AsyncRunner +import com.nextcloud.client.core.Clock import com.nextcloud.client.di.Injectable import com.nextcloud.client.di.ViewModelFactory import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.utils.extensions.getTypedActivity +import com.nextcloud.utils.extensions.searchFilesByName import com.nextcloud.utils.extensions.typedActivity import com.owncloud.android.R import com.owncloud.android.databinding.ListFragmentBinding import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.lib.common.SearchResultEntry import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.adapter.UnifiedSearchItemViewHolder import com.owncloud.android.ui.adapter.UnifiedSearchListAdapter import com.owncloud.android.ui.fragment.util.PairMediatorLiveData +import com.owncloud.android.ui.interfaces.UnifiedSearchCurrentDirItemAction import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface import com.owncloud.android.ui.unifiedsearch.IUnifiedSearchViewModel import com.owncloud.android.ui.unifiedsearch.ProviderID @@ -55,6 +62,9 @@ import com.owncloud.android.ui.unifiedsearch.filterOutHiddenFiles import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.PermissionUtil import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -67,7 +77,8 @@ class UnifiedSearchFragment : Injectable, UnifiedSearchListInterface, SearchView.OnQueryTextListener, - UnifiedSearchItemViewHolder.FilesAction { + UnifiedSearchItemViewHolder.FilesAction, + UnifiedSearchCurrentDirItemAction { private lateinit var adapter: UnifiedSearchListAdapter private var _binding: ListFragmentBinding? = null val binding get() = _binding!! @@ -77,16 +88,20 @@ class UnifiedSearchFragment : companion object { private const val TAG = "UnifiedSearchFragment" - const val ARG_QUERY = "ARG_QUERY" - const val ARG_HIDDEN_FILES = "ARG_HIDDEN_FILES" + private const val ARG_QUERY = "ARG_QUERY" + private const val ARG_HIDDEN_FILES = "ARG_HIDDEN_FILES" + private const val CURRENT_DIR_PATH = "CURRENT_DIR" - fun newInstance(query: String?, listOfHiddenFiles: ArrayList?): UnifiedSearchFragment { - val fragment = UnifiedSearchFragment() - val args = Bundle() - args.putString(ARG_QUERY, query) - args.putStringArrayList(ARG_HIDDEN_FILES, listOfHiddenFiles) - fragment.arguments = args - return fragment + fun newInstance( + query: String?, + listOfHiddenFiles: ArrayList?, + currentDirPath: String + ): UnifiedSearchFragment = UnifiedSearchFragment().apply { + arguments = Bundle().apply { + putString(ARG_QUERY, query) + putString(CURRENT_DIR_PATH, currentDirPath) + putStringArrayList(ARG_HIDDEN_FILES, listOfHiddenFiles) + } } } @@ -111,23 +126,26 @@ class UnifiedSearchFragment : @Inject lateinit var accountManager: UserAccountManager + @Inject + lateinit var appPreferences: AppPreferences + + @Inject + lateinit var clock: Clock + private var listOfHiddenFiles = ArrayList() private var showMoreActions = false + private var currentDir: OCFile? = null + private var initialQuery: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) vm = ViewModelProvider(this, vmFactory)[UnifiedSearchViewModel::class.java] - setUpViewModel() - - val query = savedInstanceState?.getString(ARG_QUERY) ?: arguments?.getString(ARG_QUERY) + initialQuery = savedInstanceState?.getString(ARG_QUERY) ?: arguments?.getString(ARG_QUERY) + val currentDirPath = savedInstanceState?.getString(CURRENT_DIR_PATH) ?: arguments?.getString(CURRENT_DIR_PATH) + currentDir = storageManager.getFileByDecryptedRemotePath(currentDirPath) listOfHiddenFiles = savedInstanceState?.getStringArrayList(ARG_HIDDEN_FILES) ?: arguments?.getStringArrayList(ARG_HIDDEN_FILES) ?: ArrayList() - - if (!query.isNullOrEmpty()) { - vm.setQuery(query) - vm.initialQuery() - } } @Suppress("DEPRECATION") @@ -149,6 +167,15 @@ class UnifiedSearchFragment : } } + override fun onResume() { + super.onResume() + typedActivity()?.run { + setupToolbar() + setMainFabVisible(false) + updateActionBarTitleAndHomeButtonByString(null) + } + } + private fun supportsOpeningCalendarContactsLocally(): Boolean = storageManager .getCapability(accountManager.user) .version @@ -181,7 +208,7 @@ class UnifiedSearchFragment : // Because this fragment is opened with TextView onClick on the previous screen maxWidth = Integer.MAX_VALUE viewThemeUtils.androidx.themeToolbarSearchView(this) - setQuery(vm.query.value, false) + setQuery(vm.query.value ?: initialQuery, false) setOnQueryTextListener(this@UnifiedSearchFragment) isIconified = false clearFocus() @@ -200,6 +227,7 @@ class UnifiedSearchFragment : vm.setQuery("") adapter.setData(emptyList()) + adapter.setDataCurrentDirItems(listOf()) showStartYourSearch() showKeyboard(searchView) @@ -282,13 +310,14 @@ class UnifiedSearchFragment : } } + @Suppress("ComplexCondition") private fun setUpViewModel() { - vm.searchResults.observe(this, this::onSearchResultChanged) - vm.isLoading.observe(this) { loading -> + vm.searchResults.observe(viewLifecycleOwner, this::onSearchResultChanged) + vm.isLoading.observe(viewLifecycleOwner) { loading -> binding.swipeContainingList.isRefreshing = loading } - PairMediatorLiveData(vm.searchResults, vm.isLoading).observe(this) { pair -> + PairMediatorLiveData(vm.searchResults, vm.isLoading).observe(viewLifecycleOwner) { pair -> if (pair.second == false) { var count = 0 @@ -296,22 +325,26 @@ class UnifiedSearchFragment : count += it.entries.size } - if (count == 0 && pair.first?.isNotEmpty() == true && context != null) { + if (count == 0 && + pair.first?.isNotEmpty() == true && + context != null && + !adapter.isCurrentDirItemsEmpty() + ) { showNoResult() } } } - vm.error.observe(this) { error -> + vm.error.observe(viewLifecycleOwner) { error -> if (!error.isNullOrEmpty()) { DisplayUtils.showSnackMessage(binding.root, error) } } - vm.browserUri.observe(this) { uri -> + vm.browserUri.observe(viewLifecycleOwner) { uri -> val browserIntent = Intent(Intent.ACTION_VIEW, uri) startActivity(browserIntent) } - vm.file.observe(this) { + vm.file.observe(viewLifecycleOwner) { showFile(it, showMoreActions) } } @@ -337,30 +370,42 @@ class UnifiedSearchFragment : } } - override fun onResume() { - super.onResume() - typedActivity()?.run { - setupToolbar() - setMainFabVisible(false) - updateActionBarTitleAndHomeButtonByString(null) - } - } - private fun setupAdapter() { + val syncedFolderProvider = SyncedFolderProvider(requireContext().contentResolver, appPreferences, clock) val gridLayoutManager = GridLayoutManager(requireContext(), 1) - adapter = UnifiedSearchListAdapter( - supportsOpeningCalendarContactsLocally(), - storageManager, - this, - this, - currentAccountProvider.user, - requireContext(), - viewThemeUtils - ) - adapter.shouldShowFooters(true) - adapter.setLayoutManager(gridLayoutManager) - binding.listRoot.layoutManager = gridLayoutManager - binding.listRoot.adapter = adapter + + lifecycleScope.launch(Dispatchers.IO) { + val client = + getTypedActivity(FileActivity::class.java)?.clientRepository?.getNextcloudClient() ?: return@launch + + withContext(Dispatchers.Main) { + adapter = UnifiedSearchListAdapter( + supportsOpeningCalendarContactsLocally(), + storageManager, + this@UnifiedSearchFragment, + this@UnifiedSearchFragment, + currentAccountProvider.user, + requireContext(), + viewThemeUtils, + appPreferences, + syncedFolderProvider, + client, + this@UnifiedSearchFragment + ) + + adapter.shouldShowFooters(true) + adapter.setLayoutManager(gridLayoutManager) + binding.listRoot.layoutManager = gridLayoutManager + binding.listRoot.adapter = adapter + searchInCurrentDirectory(initialQuery ?: "") + + setUpViewModel() + if (!initialQuery.isNullOrEmpty()) { + vm.setQuery(initialQuery!!) + vm.initialQuery() + } + } + } } override fun onSearchResultClicked(searchResultEntry: SearchResultEntry) { @@ -394,9 +439,19 @@ class UnifiedSearchFragment : override fun onQueryTextChange(newText: String?): Boolean { val closeButton = searchView?.findViewById(androidx.appcompat.R.id.search_close_btn) closeButton?.visibility = if (newText?.isEmpty() == true) View.INVISIBLE else View.VISIBLE + searchInCurrentDirectory(newText ?: "") return true } + private fun searchInCurrentDirectory(query: String) { + currentDir?.run { + val files = storageManager + .searchFilesByName(this, accountManager.user.accountName, query) + .filter { !it.isEncrypted } + adapter.setDataCurrentDirItems(files) + } + } + override fun onDestroyView() { super.onDestroyView() _binding = null @@ -406,4 +461,9 @@ class UnifiedSearchFragment : showMoreActions = true vm.openResult(searchResultEntry) } + + override fun openFile(remotePath: String, showMoreActions: Boolean) { + this.showMoreActions = showMoreActions + vm.getRemoteFile(remotePath) + } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java index 03c8f0e..99ff53f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java @@ -404,7 +404,7 @@ public class BackupListFragment extends FileFragment implements Injectable { private void closeFragment() { ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); if (contactsPreferenceActivity != null) { - contactsPreferenceActivity.onBackPressed(); + contactsPreferenceActivity.getOnBackPressedDispatcher().onBackPressed(); } } diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index 283e191..b4d0869 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -34,6 +34,7 @@ import com.nextcloud.client.account.User; import com.nextcloud.client.jobs.BackgroundJobManager; import com.nextcloud.client.jobs.download.FileDownloadHelper; import com.nextcloud.client.jobs.upload.FileUploadHelper; +import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.utils.EditorUtils; import com.owncloud.android.MainApp; @@ -42,6 +43,7 @@ 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.UploadsStorageManager; import com.owncloud.android.files.StreamMediaFileOperation; import com.owncloud.android.lib.common.operations.RemoteOperationResult; import com.owncloud.android.lib.common.utils.Log_OC; @@ -86,7 +88,6 @@ import java.util.Collection; import java.util.Date; import java.util.List; import java.util.Locale; -import java.util.NoSuchElementException; import java.util.Optional; import java.util.Set; import java.util.regex.Matcher; @@ -97,6 +98,7 @@ import androidx.annotation.Nullable; import androidx.core.content.FileProvider; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; +import kotlin.Unit; /** * Helper implementation for file operations locally and remote. @@ -1006,17 +1008,22 @@ public class FileOperationsHelper { } } - if (FileDownloadHelper.Companion.instance().isDownloading(currentUser, file)) { + final var fileDownloadHelper = FileDownloadHelper.Companion.instance(); + if (fileDownloadHelper.isDownloading(currentUser, file)) { List files = fileActivity.getStorageManager().getAllFilesRecursivelyInsideFolder(file); - FileDownloadHelper.Companion.instance().cancelPendingOrCurrentDownloads(currentUser, files); + fileDownloadHelper.cancelPendingOrCurrentDownloads(currentUser, files); } - if (FileUploadHelper.Companion.instance().isUploading(currentUser, file)) { - try { - FileUploadHelper.Companion.instance().cancelFileUpload(file.getRemotePath(), currentUser.getAccountName()); - } catch (NoSuchElementException e) { - Log_OC.e(TAG, "Error cancelling current upload because user does not exist!"); - } + if (file.isFolder()) { + fileDownloadHelper.cancelFolderDownload(); + } + + final var fileUploadHelper = FileUploadHelper.Companion.instance(); + if (fileUploadHelper.isUploading(file.getRemotePath(), currentUser.getAccountName())) { + FileUploadWorker.Companion.cancelCurrentUpload(file.getRemotePath(), currentUser.getAccountName(), () -> { + fileUploadHelper.updateUploadStatus(file.getRemotePath(), currentUser.getAccountName(), UploadsStorageManager.UploadStatus.UPLOAD_CANCELLED); + return Unit.INSTANCE; + }); } } diff --git a/app/src/main/java/com/owncloud/android/ui/interfaces/UnifiedSearchCurrentDirItemAction.kt b/app/src/main/java/com/owncloud/android/ui/interfaces/UnifiedSearchCurrentDirItemAction.kt new file mode 100644 index 0000000..e328546 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/interfaces/UnifiedSearchCurrentDirItemAction.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.interfaces + +interface UnifiedSearchCurrentDirItemAction { + fun openFile(remotePath: String, showMoreActions: Boolean) +} diff --git a/app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.kt b/app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.kt index c125d80..61fbc9d 100644 --- a/app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.kt +++ b/app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.kt @@ -23,6 +23,7 @@ object NotificationUtils { const val NOTIFICATION_CHANNEL_PUSH: String = "NOTIFICATION_CHANNEL_PUSH" const val NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS: String = "NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS" const val NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS: String = "NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS" + const val NOTIFICATION_CHANNEL_CONTENT_OBSERVER: String = "NOTIFICATION_CHANNEL_CONTENT_OBSERVER" fun createUploadNotificationTag(file: OCFile): String = createUploadNotificationTag(file.remotePath, file.storagePath) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewBitmapActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewBitmapActivity.kt index c526501..b6c9e86 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewBitmapActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewBitmapActivity.kt @@ -54,7 +54,7 @@ class PreviewBitmapActivity : } override fun onSupportNavigateUp(): Boolean { - onBackPressed() + onBackPressedDispatcher.onBackPressed() return true } } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index c71c86b..e45c42c 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -14,6 +14,7 @@ import android.os.Build import android.os.Bundle import android.view.MenuItem import android.view.View +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.ActionBar import androidx.core.content.ContextCompat import androidx.drawerlayout.widget.DrawerLayout @@ -55,6 +56,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings import java.io.Serializable import javax.inject.Inject import kotlin.math.max +import kotlin.math.min /** * Holds a swiping gallery where image files contained in an Nextcloud directory are shown. @@ -123,6 +125,7 @@ class PreviewImageActivity : observeWorkerState() applyDisplayCutOutTopPadding() + handleBackPress() } private fun applyDisplayCutOutTopPadding() { @@ -174,7 +177,7 @@ class PreviewImageActivity : ) } else { // get parent from path - var parentFolder = storageManager.getFileById(file.parentId) + var parentFolder = file?.let { storageManager.getFileById(it.parentId) } if (parentFolder == null) { // should not be necessary @@ -194,7 +197,13 @@ class PreviewImageActivity : viewPager = findViewById(R.id.fragmentPager) - var position = if (savedPosition != null) savedPosition else previewImagePagerAdapter?.getFilePosition(file) + var position = if (savedPosition != + null + ) { + savedPosition + } else { + file?.let { previewImagePagerAdapter?.getFilePosition(it) } + } position = position?.toDouble()?.let { max(it, 0.0).toInt() } viewPager?.adapter = previewImagePagerAdapter @@ -207,16 +216,32 @@ class PreviewImageActivity : viewPager?.setCurrentItem(position, false) } - if (position == 0 && !file.isDown) { + if (position == 0 && file?.isDown == false) { // this is necessary because mViewPager.setCurrentItem(0) just after setting the // adapter does not result in a call to #onPageSelected(0) screenState = PreviewImageActivityState.WaitingForBinder } } - override fun onBackPressed() { - sendRefreshSearchEventBroadcast() - super.onBackPressed() + private fun updateViewPagerAfterDeletionAndAdvanceForward() { + val deletePosition = viewPager?.currentItem ?: return + previewImagePagerAdapter?.let { adapter -> + val nextPosition = min(deletePosition, adapter.itemCount - 1) + viewPager?.setCurrentItem(nextPosition, true) + adapter.delete(deletePosition) + // Page needs to be reselected after the adapter has been updated. Otherwise, wrong title is shown + selectPage(nextPosition) + } + } + + private fun handleBackPress() { + onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + sendRefreshSearchEventBroadcast() + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + }) } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -256,7 +281,7 @@ class PreviewImageActivity : if (file != null) { // / Refresh the activity according to the Account and OCFile set setFile(file) // reset after getting it fresh from storageManager - updateActionBarTitle(getFile().fileName) + updateActionBarTitle(getFile()?.fileName) // if (!stateWasRecovered) { initViewPager(optionalUser.get()) @@ -278,9 +303,6 @@ class PreviewImageActivity : super.onRemoteOperationFinish(operation, result) if (operation is RemoveFileOperation) { - val deletePosition = viewPager?.currentItem ?: return - val nextPosition = if (deletePosition > 0) deletePosition - 1 else 0 - previewImagePagerAdapter?.let { if (it.itemCount <= 1) { backToDisplayActivity() @@ -288,12 +310,9 @@ class PreviewImageActivity : } } - if (user.isPresent) { - initViewPager(user.get()) + if (result.isSuccess) { + updateViewPagerAfterDeletionAndAdvanceForward() } - - viewPager?.setCurrentItem(nextPosition, true) - previewImagePagerAdapter?.delete(deletePosition) } else if (operation is SynchronizeFileOperation) { onSynchronizeFileOperationFinish(result) } @@ -340,8 +359,10 @@ class PreviewImageActivity : savedPosition?.let { position -> previewImagePagerAdapter?.run { - updateFile(position, file) - notifyItemChanged(position) + file?.let { + updateFile(position, it) + notifyItemChanged(position) + } } if (user.isPresent) { @@ -365,7 +386,9 @@ class PreviewImageActivity : dismissLoadingDialog() screenState = PreviewImageActivityState.Idle file = downloadedFile - startEditImageActivity(file) + file?.let { + startEditImageActivity(it) + } } override fun onResume() { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt index 430e330..9f75a69 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt @@ -63,7 +63,9 @@ class PreviewImagePagerAdapter : FragmentStateAdapter { imageFiles = mStorageManager.getFolderImages(parentFolder, onlyOnDevice) val sortOrder = preferences.getSortOrderByFolder(parentFolder) - imageFiles = sortOrder.sortCloudFiles(imageFiles.toMutableList()).toMutableList() + val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() + val favoritesFirst = preferences.isSortFavoritesFirst() + imageFiles = sortOrder.sortCloudFiles(imageFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) mObsoleteFragments = HashSet() mObsoletePositions = HashSet() @@ -101,7 +103,9 @@ class PreviewImagePagerAdapter : FragmentStateAdapter { if (type == VirtualFolderType.FAVORITE) { val sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) - imageFiles = sortOrder.sortCloudFiles(imageFiles.toMutableList()).toMutableList() + val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() + val favoritesFirst = preferences.isSortFavoritesFirst() + imageFiles = sortOrder.sortCloudFiles(imageFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) } mObsoleteFragments = HashSet() @@ -201,4 +205,11 @@ class PreviewImagePagerAdapter : FragmentStateAdapter { override fun createFragment(position: Int): Fragment = getItem(position) override fun getItemCount(): Int = imageFiles.size + + override fun getItemId(position: Int): Long { + // The item ID function is needed to detect whether the deletion of the current item needs a UI update + return imageFiles.getOrNull(position)?.fileId ?: position.toLong() + } + + override fun containsItem(itemId: Long): Boolean = imageFiles.any { it.fileId == itemId } } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt index c15405d..30266b0 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt @@ -384,18 +384,19 @@ class PreviewMediaActivity : @Suppress("TooGenericExceptionCaught") private fun playAudio() { - if (file.isDown) { - prepareAudioPlayer(file.storageUri) + if (file?.isDown == true) { + prepareAudioPlayer(file?.storageUri) } else { try { - LoadStreamUrl(this, user, clientFactory).execute(file.localId) + LoadStreamUrl(this, user, clientFactory).execute(file?.localId) } catch (e: Exception) { Log_OC.e(TAG, "Loading stream url for Audio not possible: $e") } } } - private fun prepareAudioPlayer(uri: Uri) { + private fun prepareAudioPlayer(uri: Uri?) { + uri ?: return audioMediaController?.let { audioPlayer -> audioPlayer.addListener(object : Player.Listener { @@ -434,7 +435,7 @@ class PreviewMediaActivity : }) val mediaItem = MediaItem.Builder() .setUri(uri) - .setMediaMetadata(MediaMetadata.Builder().setTitle(file.fileName).build()) + .setMediaMetadata(MediaMetadata.Builder().setTitle(file?.fileName).build()) .build() audioPlayer.setMediaItem(mediaItem) audioPlayer.playWhenReady = autoplay @@ -563,8 +564,8 @@ class PreviewMediaActivity : R.id.action_remove_file -> { videoPlayer?.pause() - val dialog = RemoveFilesDialogFragment.newInstance(file) - dialog.show(supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) + val dialog = file?.let { RemoveFilesDialogFragment.newInstance(it) } + dialog?.show(supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) } R.id.action_see_details -> { @@ -572,7 +573,7 @@ class PreviewMediaActivity : } R.id.action_sync_file -> { - showSyncLoadingDialog(file.isFolder) + showSyncLoadingDialog(file?.isFolder == true) fileOperationsHelper.syncFile(file) } @@ -586,7 +587,7 @@ class PreviewMediaActivity : R.id.action_export_file -> { val list = ArrayList() - list.add(file) + file?.let { list.add(it) } fileOperationsHelper.exportFiles( list, this, @@ -673,18 +674,19 @@ class PreviewMediaActivity : private fun playVideo() { setupVideoView() - if (file.isDown) { - prepareVideoPlayer(file.storageUri) + if (file?.isDown == true) { + prepareVideoPlayer(file?.storageUri) } else { try { - LoadStreamUrl(this, user, clientFactory).execute(file.localId) + LoadStreamUrl(this, user, clientFactory).execute(file?.localId) } catch (e: Exception) { Log_OC.e(TAG, "Loading stream url for Video not possible: $e") } } } - private fun prepareVideoPlayer(uri: Uri) { + private fun prepareVideoPlayer(uri: Uri?) { + uri ?: return binding.progress.visibility = View.GONE val videoMediaItem = MediaItem.fromUri(uri) videoPlayer?.run { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java b/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java index d788e38..9be0339 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java @@ -7,6 +7,7 @@ */ package com.owncloud.android.ui.preview; +import android.annotation.SuppressLint; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; @@ -146,6 +147,7 @@ public class PreviewTextFileFragment extends PreviewTextFragment { /** * Reads the file to preview and shows its contents. Too critical to be anonymous. */ + @SuppressLint("StaticFieldLeak") private class TextLoadAsyncTask extends AsyncTask { private static final int PARAMS_LENGTH = 1; private final WeakReference textViewReference; diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFragment.kt index a3aec17..99b0bff 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFragment.kt @@ -165,7 +165,7 @@ abstract class PreviewTextFragment : * Finishes the preview */ protected fun finish() { - requireActivity().runOnUiThread { requireActivity().onBackPressed() } + requireActivity().runOnUiThread { requireActivity().onBackPressedDispatcher.onBackPressed() } } companion object { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt index 30b9da6..dd1e681 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt @@ -7,15 +7,16 @@ */ package com.owncloud.android.ui.preview -import android.app.Activity import android.app.Dialog import android.os.Build import android.view.ViewGroup import android.view.Window +import androidx.activity.addCallback import androidx.annotation.OptIn import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.fragment.app.FragmentActivity import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView @@ -35,7 +36,7 @@ import com.owncloud.android.lib.common.utils.Log_OC */ @OptIn(UnstableApi::class) class PreviewVideoFullscreenDialog( - private val activity: Activity, + private val activity: FragmentActivity, nextcloudClient: NextcloudClient, private val sourceExoPlayer: ExoPlayer, private val sourceView: PlayerView @@ -69,6 +70,7 @@ class PreviewVideoFullscreenDialog( binding.videoPlayer.player = mExoPlayer mExoPlayer.prepare() } + handleOnBackPressed() } private fun isRotatedVideo(): Boolean { @@ -95,7 +97,7 @@ class PreviewVideoFullscreenDialog( setOnShowListener { enableImmersiveMode() switchTargetViewFromSource() - binding.videoPlayer.setFullscreenButtonClickListener { onBackPressed() } + binding.videoPlayer.setFullscreenButtonClickListener { activity.onBackPressedDispatcher.onBackPressed() } if (isPlaying) { mExoPlayer.play() } @@ -111,23 +113,25 @@ class PreviewVideoFullscreenDialog( } } - override fun onBackPressed() { - val isPlaying = mExoPlayer.isPlaying - if (isPlaying) { - mExoPlayer.pause() - } - setOnDismissListener { - disableImmersiveMode() - playingStateListener?.let { - mExoPlayer.removeListener(it) - } - switchTargetViewToSource() + private fun handleOnBackPressed() { + activity.onBackPressedDispatcher.addCallback(activity) { + val isPlaying = mExoPlayer.isPlaying if (isPlaying) { - sourceExoPlayer.play() + mExoPlayer.pause() } - sourceView.showController() + setOnDismissListener { + disableImmersiveMode() + playingStateListener?.let { + mExoPlayer.removeListener(it) + } + switchTargetViewToSource() + if (isPlaying) { + sourceExoPlayer.play() + } + sourceView.showController() + } + dismiss() } - dismiss() } private fun switchTargetViewToSource() { diff --git a/app/src/main/java/com/owncloud/android/ui/trashbin/TrashbinActivity.kt b/app/src/main/java/com/owncloud/android/ui/trashbin/TrashbinActivity.kt index 64a103b..2ceef02 100644 --- a/app/src/main/java/com/owncloud/android/ui/trashbin/TrashbinActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/trashbin/TrashbinActivity.kt @@ -128,6 +128,7 @@ class TrashbinActivity : updateActionBarTitleAndHomeButtonByString(getString(R.string.trashbin_activity_title)) setupDrawer() + handleBackPress() } override fun onStart() { @@ -179,8 +180,6 @@ class TrashbinActivity : loadFolder() - handleOnBackPressed() - mMultiChoiceModeListener = MultiChoiceModeListener( this, trashbinListAdapter, @@ -189,7 +188,7 @@ class TrashbinActivity : addDrawerListener(mMultiChoiceModeListener) } - private fun handleOnBackPressed() { + private fun handleBackPress() { onBackPressedDispatcher.addCallback( this, onBackPressedCallback @@ -390,7 +389,7 @@ class TrashbinActivity : binding.emptyList.emptyListViewHeadline.visibility = View.VISIBLE binding.emptyList.emptyListViewText.visibility = View.VISIBLE binding.emptyList.emptyListView.visibility = View.VISIBLE - binding.emptyList.emptyListIcon.setImageResource(R.drawable.ic_cloud_sync_off) + binding.emptyList.emptyListIcon.setImageResource(R.drawable.ic_sync_off) } private fun openActionsMenu(filesCount: Int, checkedFiles: Set) { diff --git a/app/src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchViewModel.kt b/app/src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchViewModel.kt index 449fdf5..04c7d0e 100644 --- a/app/src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchViewModel.kt +++ b/app/src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchViewModel.kt @@ -23,4 +23,6 @@ interface IUnifiedSearchViewModel { fun loadMore(provider: ProviderID) fun openResult(result: SearchResultEntry) fun setQuery(query: String) + fun openFile(remotePath: String) + fun getRemoteFile(remotePath: String) } diff --git a/app/src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt b/app/src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt index aaa82a6..97ba8e4 100644 --- a/app/src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt +++ b/app/src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt @@ -156,21 +156,25 @@ class UnifiedSearchViewModel(application: Application) : } } - fun openFile(fileUrl: String) { + override fun openFile(remotePath: String) { if (isLoading.value == false) { isLoading.value = true - val user = currentAccountProvider.user - val task = GetRemoteFileTask( - context, - fileUrl, - clientFactory.create(currentAccountProvider.user), - FileDataStorageManager(user, context.contentResolver), - user - ) - runner.postQuickTask(task, onResult = this::onFileRequestResult) + getRemoteFile(remotePath) } } + override fun getRemoteFile(remotePath: String) { + val user = currentAccountProvider.user + val task = GetRemoteFileTask( + context, + remotePath, + clientFactory.create(user), + FileDataStorageManager(user, context.contentResolver), + user + ) + runner.postQuickTask(task, onResult = this::onFileRequestResult) + } + fun onError(error: Throwable) { Log_OC.e(TAG, "Error: " + error.stackTrace) } diff --git a/app/src/main/java/com/owncloud/android/utils/BitmapUtils.java b/app/src/main/java/com/owncloud/android/utils/BitmapUtils.java index 881661c..064fd73 100644 --- a/app/src/main/java/com/owncloud/android/utils/BitmapUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/BitmapUtils.java @@ -15,8 +15,6 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.BitmapFactory.Options; import android.graphics.Canvas; -import android.graphics.ImageDecoder; -import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; @@ -25,10 +23,9 @@ import android.graphics.Rect; import android.graphics.RectF; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; -import android.os.Build; -import android.text.TextUtils; import android.widget.ImageView; +import com.nextcloud.utils.BitmapExtensionsKt; import com.owncloud.android.MainApp; import com.owncloud.android.R; import com.owncloud.android.lib.common.utils.Log_OC; @@ -36,8 +33,6 @@ import com.owncloud.android.lib.resources.users.Status; import com.owncloud.android.lib.resources.users.StatusType; import com.owncloud.android.ui.StatusDrawable; -import java.io.File; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -45,12 +40,13 @@ import java.util.Locale; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import androidx.core.graphics.drawable.RoundedBitmapDrawable; import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; import androidx.exifinterface.media.ExifInterface; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import static com.nextcloud.utils.extensions.ThumbnailsCacheManagerExtensionsKt.getExifOrientation; + /** * Utility class with methods for decoding Bitmaps. */ @@ -77,23 +73,6 @@ public final class BitmapUtils { return resultBitmap; } - @Nullable - @RequiresApi(Build.VERSION_CODES.P) - private static Bitmap decodeSampledBitmapViaImageDecoder(@NonNull File file, int reqWidth, int reqHeight) { - try { - Log_OC.i(TAG, "Decoding Bitmap via ImageDecoder"); - - final var imageDecoderSource = ImageDecoder.createSource(file); - - return ImageDecoder.decodeBitmap(imageDecoderSource, (decoder, info, source1) -> decoder.setTargetSize(reqWidth, reqHeight) - ); - - } catch (IOException exception) { - Log_OC.w(TAG, "Decoding Bitmap via ImageDecoder failed, BitmapFactory.decodeFile will be used"); - return null; - } - } - /** * Decodes a bitmap from a file containing it minimizing the memory use, known that the bitmap will be drawn in a * surface of reqWidth x reqHeight @@ -105,41 +84,7 @@ public final class BitmapUtils { */ @Nullable public static Bitmap decodeSampledBitmapFromFile(String srcPath, int reqWidth, int reqHeight) { - if (TextUtils.isEmpty(srcPath)) { - Log_OC.e(TAG, "srcPath is null or empty"); - return null; - } - - final var file = new File(srcPath); - if (!file.exists()) { - Log_OC.e(TAG, "File does not exists, returning null"); - return null; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - final var result = decodeSampledBitmapViaImageDecoder(file, reqWidth, reqHeight); - if (result != null) { - return result; - } - } - - Log_OC.i(TAG, "Decoding Bitmap via BitmapFactory.decodeFile"); - - // set desired options that will affect the size of the bitmap - final Options options = new Options(); - - // make a false load of the bitmap to get its dimensions - options.inJustDecodeBounds = true; - - // FIXME after auto-rename can't generate thumbnail from localPath - BitmapFactory.decodeFile(srcPath, options); - - // calculate factor to subsample the bitmap - options.inSampleSize = calculateSampleFactor(options, reqWidth, reqHeight); - - // decode bitmap with inSampleSize set - options.inJustDecodeBounds = false; - return BitmapFactory.decodeFile(srcPath, options); + return BitmapExtensionsKt.decodeSampledBitmapFromFile(srcPath, reqWidth, reqHeight); } /** @@ -153,15 +98,6 @@ public final class BitmapUtils { var originalWidth = bitmapResolution[0]; var originalHeight = bitmapResolution[1]; - // Detect Orientation and swap height/width if the image is to be rotated - var shouldRotate = detectRotateImage(storagePath); - if (shouldRotate) { - // Swap the width and height - var tempWidth = originalWidth; - originalWidth = originalHeight; - originalHeight = tempWidth; - } - // Calculate the scaling factors based on screen dimensions var widthScaleFactor = (float) minWidth/ originalWidth; var heightScaleFactor = (float) minHeight / originalHeight; @@ -173,7 +109,14 @@ public final class BitmapUtils { var scaledWidth = (int) (originalWidth * scaleFactor); var scaledHeight = (int) (originalHeight * scaleFactor); - return decodeSampledBitmapFromFile(storagePath, scaledWidth, scaledHeight); + var shouldRotate = detectRotateImage(storagePath); + var result = decodeSampledBitmapFromFile(storagePath, scaledWidth, scaledHeight); + if (shouldRotate) { + int orientation = getExifOrientation(storagePath); + return BitmapExtensionsKt.rotateBitmapViaExif(result, orientation); + } else { + return result; + } } /** * Calculates a proper value for options.inSampleSize in order to decode a Bitmap minimizing the memory overload and @@ -222,74 +165,6 @@ public final class BitmapUtils { return Bitmap.createScaledBitmap(bitmap, w, h, true); } - /** - * Rotate bitmap according to EXIF orientation. Cf. http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/ - * - * @param bitmap Bitmap to be rotated - * @param storagePath Path to source file of bitmap. Needed for EXIF information. - * @return correctly EXIF-rotated bitmap - */ - public static Bitmap rotateImage(Bitmap bitmap, String storagePath) { - Bitmap resultBitmap = bitmap; - - try { - ExifInterface exifInterface = new ExifInterface(storagePath); - int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, 1); - - if (orientation != ExifInterface.ORIENTATION_NORMAL) { - Matrix matrix = new Matrix(); - switch (orientation) { - // 2 - case ExifInterface.ORIENTATION_FLIP_HORIZONTAL: { - matrix.postScale(-1.0f, 1.0f); - break; - } - // 3 - case ExifInterface.ORIENTATION_ROTATE_180: { - matrix.postRotate(180); - break; - } - // 4 - case ExifInterface.ORIENTATION_FLIP_VERTICAL: { - matrix.postScale(1.0f, -1.0f); - break; - } - // 5 - case ExifInterface.ORIENTATION_TRANSPOSE: { - matrix.postRotate(-90); - matrix.postScale(1.0f, -1.0f); - break; - } - // 6 - case ExifInterface.ORIENTATION_ROTATE_90: { - matrix.postRotate(90); - break; - } - // 7 - case ExifInterface.ORIENTATION_TRANSVERSE: { - matrix.postRotate(90); - matrix.postScale(1.0f, -1.0f); - break; - } - // 8 - case ExifInterface.ORIENTATION_ROTATE_270: { - matrix.postRotate(270); - break; - } - } - - // Rotate the bitmap - resultBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); - if (!resultBitmap.equals(bitmap)) { - bitmap.recycle(); - } - } - } catch (Exception exception) { - Log_OC.e("BitmapUtil", "Could not rotate the image: " + storagePath); - } - return resultBitmap; - } - /** * Detect if Image will be rotated according to EXIF orientation. Cf. http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/ * diff --git a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java index 795bd43..dfedc9f 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -79,6 +79,7 @@ import java.net.IDN; import java.nio.charset.Charset; import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -828,7 +829,7 @@ public final class DisplayUtils { private static void setThumbnailFirstTimeForFile(OCFile file, ImageView thumbnailView, FileDataStorageManager storageManager, List asyncTasks, boolean gridView, LoaderImageView shimmerThumbnail, User user, AppPreferences preferences, Context context, ViewThemeUtils viewThemeUtils) { if (file.getRemoteId() != null) { - generateNewThumbnail(file, thumbnailView, user, storageManager, asyncTasks, gridView, context, shimmerThumbnail, preferences, viewThemeUtils); + generateNewThumbnail(file, thumbnailView, user, storageManager, new ArrayList<>(asyncTasks), gridView, context, shimmerThumbnail, preferences, viewThemeUtils); return; } @@ -873,7 +874,7 @@ public final class DisplayUtils { private static void setThumbnailFromCache(OCFile file, ImageView thumbnailView, FileDataStorageManager storageManager, List asyncTasks, boolean gridView, LoaderImageView shimmerThumbnail, User user, AppPreferences preferences, Context context, ViewThemeUtils viewThemeUtils) { final var thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.getRemoteId()); if (thumbnail == null || file.isUpdateThumbnailNeeded()) { - generateNewThumbnail(file, thumbnailView, user, storageManager, asyncTasks, gridView, context, shimmerThumbnail, preferences, viewThemeUtils); + generateNewThumbnail(file, thumbnailView, user, storageManager, new ArrayList<>(asyncTasks), gridView, context, shimmerThumbnail, preferences, viewThemeUtils); setThumbnailBackgroundForPNGFileIfNeeded(file, context, thumbnailView); return; } @@ -901,7 +902,7 @@ public final class DisplayUtils { ImageView thumbnailView, User user, FileDataStorageManager storageManager, - List asyncTasks, + ArrayList asyncTasks, boolean gridView, Context context, LoaderImageView shimmerThumbnail, @@ -986,7 +987,7 @@ public final class DisplayUtils { new ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, file.getRemoteId())); thumbnailView.invalidate(); - } catch (IllegalArgumentException e) { + } catch (Exception e) { Log_OC.d(TAG, "ThumbnailGenerationTask : " + e.getMessage()); } } diff --git a/app/src/main/java/com/owncloud/android/utils/FileSortOrder.kt b/app/src/main/java/com/owncloud/android/utils/FileSortOrder.kt index 0767754..b2b8c3d 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileSortOrder.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileSortOrder.kt @@ -80,7 +80,7 @@ open class FileSortOrder(@JvmField var name: String, var isAscending: Boolean) { * @param files files to sort */ @JvmStatic - fun sortCloudFilesByFavourite(files: MutableList): List { + fun sortCloudFilesByFavourite(files: MutableList): MutableList { files.sortWith { o1: OCFile, o2: OCFile -> when { o1.isFavorite && o2.isFavorite -> 0 @@ -91,9 +91,46 @@ open class FileSortOrder(@JvmField var name: String, var isAscending: Boolean) { } return files } + + /** + * Sorts list by Folders. + * + * @param files files to sort + */ + @JvmStatic + fun sortCloudFilesByFolderFirst(files: MutableList): MutableList { + files.sortWith { o1: OCFile, o2: OCFile -> + when { + o1.isFolder && o2.isFolder -> 0 + o1.isFolder -> -1 + o2.isFolder -> 1 + else -> 0 + } + } + return files + } } - open fun sortCloudFiles(files: MutableList): List = sortCloudFilesByFavourite(files) + @Suppress("ReturnCount") + open fun sortCloudFiles( + files: MutableList, + foldersBeforeFiles: Boolean, + favoritesFirst: Boolean + ): MutableList { + if (foldersBeforeFiles && favoritesFirst) { + return sortCloudFilesByFavourite(sortCloudFilesByFolderFirst(files)) + } + + if (foldersBeforeFiles) { + return sortCloudFilesByFolderFirst(files) + } + + if (favoritesFirst) { + return sortCloudFilesByFavourite(files) + } + + return files + } open fun sortLocalFiles(files: MutableList): List = files diff --git a/app/src/main/java/com/owncloud/android/utils/FileSortOrderByDate.kt b/app/src/main/java/com/owncloud/android/utils/FileSortOrderByDate.kt index d62f076..6d59836 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileSortOrderByDate.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileSortOrderByDate.kt @@ -20,12 +20,16 @@ class FileSortOrderByDate(name: String, ascending: Boolean) : FileSortOrder(name * * @param files list of files to sort */ - override fun sortCloudFiles(files: MutableList): List { + override fun sortCloudFiles( + files: MutableList, + foldersBeforeFiles: Boolean, + favoritesFirst: Boolean + ): MutableList { val multiplier = if (isAscending) 1 else -1 files.sortWith { o1: OCFile, o2: OCFile -> multiplier * o1.modificationTimestamp.compareTo(o2.modificationTimestamp) } - return super.sortCloudFiles(files) + return super.sortCloudFiles(files, foldersBeforeFiles, favoritesFirst) } /** diff --git a/app/src/main/java/com/owncloud/android/utils/FileSortOrderByName.kt b/app/src/main/java/com/owncloud/android/utils/FileSortOrderByName.kt index 5b27b63..cb39a2a 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileSortOrderByName.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileSortOrderByName.kt @@ -24,9 +24,13 @@ class FileSortOrderByName internal constructor(name: String?, ascending: Boolean * @param files files to sort */ @SuppressFBWarnings("Bx") - override fun sortCloudFiles(files: MutableList): List { - val sortedByName = sortServerFiles(files) - return super.sortCloudFiles(sortedByName) + override fun sortCloudFiles( + files: MutableList, + foldersBeforeFiles: Boolean, + favoritesFirst: Boolean + ): MutableList { + val sortedByName = sortOnlyByName(files) + return super.sortCloudFiles(sortedByName, foldersBeforeFiles, favoritesFirst) } /** @@ -51,6 +55,11 @@ class FileSortOrderByName internal constructor(name: String?, ascending: Boolean return files } + private fun sortOnlyByName(files: MutableList): MutableList { + files.sortWith { o1: OCFile, o2: OCFile -> sortMultiplier * AlphanumComparator.compare(o1, o2) } + return files + } + /** * Sorts list by Name. * diff --git a/app/src/main/java/com/owncloud/android/utils/FileSortOrderBySize.kt b/app/src/main/java/com/owncloud/android/utils/FileSortOrderBySize.kt index fbe999a..5b188f1 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileSortOrderBySize.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileSortOrderBySize.kt @@ -17,9 +17,13 @@ import java.io.File */ class FileSortOrderBySize internal constructor(name: String?, ascending: Boolean) : FileSortOrder(name!!, ascending) { - override fun sortCloudFiles(files: MutableList): List { + override fun sortCloudFiles( + files: MutableList, + foldersBeforeFiles: Boolean, + favoritesFirst: Boolean + ): MutableList { val sortedBySize = sortServerFiles(files) - return super.sortCloudFiles(sortedBySize) + return super.sortCloudFiles(sortedBySize, foldersBeforeFiles, favoritesFirst) } override fun sortTrashbinFiles(files: MutableList): List { @@ -40,10 +44,14 @@ class FileSortOrderBySize internal constructor(name: String?, ascending: Boolean } override fun sortLocalFiles(files: MutableList): List { + val folderSizes = + files.associateWith { file -> FileStorageUtils.getFolderSize(file) } + files.sortWith { o1: File, o2: File -> when { - o1.isDirectory && o2.isDirectory -> sortMultiplier * FileStorageUtils.getFolderSize(o1) - .compareTo(FileStorageUtils.getFolderSize(o2)) + o1.isDirectory && o2.isDirectory -> sortMultiplier * (folderSizes[o1] ?: 0L).compareTo( + folderSizes[o2] ?: 0L + ) o1.isDirectory -> -1 o2.isDirectory -> 1 else -> sortMultiplier * o1.length().compareTo(o2.length()) diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java index 550b0e8..b9988cb 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java @@ -11,6 +11,7 @@ package com.owncloud.android.utils; import android.Manifest; +import android.annotation.SuppressLint; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Resources; @@ -37,9 +38,9 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -80,8 +81,8 @@ public final class FileStorageUtils { String decoded; try { decoded = URLDecoder.decode(filename, StandardCharsets.UTF_8.toString()); - } catch (UnsupportedEncodingException e) { - return false; + } catch (Exception e) { + decoded = filename; } int[] bidiControlCharacters = { @@ -231,6 +232,7 @@ public final class FileStorageUtils { * * @return Optimistic number of available bytes (can be less) */ + @SuppressLint("UsableSpace") public static long getUsableSpace() { File savePath = new File(MainApp.getStoragePath()); return savePath.getUsableSpace(); @@ -404,24 +406,28 @@ public final class FileStorageUtils { * @return Size in bytes */ public static long getFolderSize(File dir) { - if (dir.exists() && dir.isDirectory()) { - File[] files = dir.listFiles(); - - if (files != null) { - long result = 0; - for (File f : files) { - if (f.isDirectory()) { - result += getFolderSize(f); - } else { - result += f.length(); - } - } - return result; - } + if (dir == null || !dir.exists() || !dir.isDirectory()) { + return 0; } - return 0; + + File[] files = dir.listFiles(); + if (files == null) { + return 0; + } + + long result = 0; + for (File f : files) { + if (f.isDirectory()) { + result += getFolderSize(f); + continue; + } + result += f.length(); + } + + return result; } + /** * Mimetype String of a file. * @@ -531,7 +537,11 @@ public final class FileStorageUtils { } storageManager.deleteFileInMediaScan(file.getAbsolutePath()); - file.delete(); + try { + Files.deleteIfExists(file.toPath()); + } catch (Exception e) { + Log_OC.e("Error deleting file: ", e.getMessage()); + } } public static boolean deleteRecursive(File file) { diff --git a/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.java b/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.java index 9dfa3fa..94461e0 100644 --- a/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.java +++ b/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.java @@ -18,9 +18,11 @@ import android.provider.MediaStore; 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.ContentObserverWork; +import com.nextcloud.client.jobs.autoUpload.AutoUploadHelper; import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.utils.extensions.UriExtensionsKt; import com.owncloud.android.MainApp; import com.owncloud.android.datamodel.FilesystemDataProvider; import com.owncloud.android.datamodel.MediaFolderType; @@ -29,20 +31,7 @@ import com.owncloud.android.datamodel.SyncedFolderProvider; import com.owncloud.android.datamodel.UploadsStorageManager; import com.owncloud.android.lib.common.utils.Log_OC; -import org.lukhnos.nnio.file.AccessDeniedException; -import org.lukhnos.nnio.file.FileVisitResult; -import org.lukhnos.nnio.file.FileVisitor; -import org.lukhnos.nnio.file.Path; -import org.lukhnos.nnio.file.Paths; -import org.lukhnos.nnio.file.SimpleFileVisitor; -import org.lukhnos.nnio.file.attribute.BasicFileAttributes; -import org.lukhnos.nnio.file.Files; -import org.lukhnos.nnio.file.impl.FileBasedPathImpl; - import java.io.File; -import java.io.IOException; -import java.util.Arrays; -import java.util.Collections; import static com.owncloud.android.datamodel.OCFile.PATH_SEPARATOR; @@ -58,110 +47,7 @@ public final class FilesSyncHelper { // utility class -> private constructor } - /** - * Copy of {@link Files#walkFileTree(Path, FileVisitor)} that walks the file tree in random order. - * - * @see org.lukhnos.nnio.file.Files#walkFileTree(Path, FileVisitor) - */ - public static void walkFileTreeRandomly(Path start, FileVisitor visitor) throws IOException { - File file = start.toFile(); - if (!file.canRead()) { - Log_OC.d(TAG, "walkFileTreeRandomly, cant read the file: " + file.getAbsolutePath()); - visitor.visitFileFailed(start, new AccessDeniedException(file.toString())); - } else { - Log_OC.d(TAG, "walkFileTreeRandomly, reading file: " + file.getAbsolutePath()); - - if (Files.isDirectory(start)) { - Log_OC.d(TAG, "walkFileTreeRandomly, file is directory: " + file.getAbsolutePath()); - - FileVisitResult preVisitDirectoryResult = visitor.preVisitDirectory(start, null); - if (preVisitDirectoryResult == FileVisitResult.CONTINUE) { - Log_OC.d(TAG, "walkFileTreeRandomly, preVisitDirectoryResult == FileVisitResult.CONTINUE"); - File[] children = start.toFile().listFiles(); - if (children != null) { - Log_OC.d(TAG, "walkFileTreeRandomly, children exists"); - - Collections.shuffle(Arrays.asList(children)); - File[] var5 = children; - int var6 = children.length; - - for(int var7 = 0; var7 < var6; ++var7) { - Log_OC.d(TAG, "walkFileTreeRandomly -- recursive call"); - File child = var5[var7]; - walkFileTreeRandomly(FileBasedPathImpl.get(child), visitor); - } - - visitor.postVisitDirectory(start, null); - } else { - Log_OC.w(TAG, "walkFileTreeRandomly, children is null"); - } - } else { - Log_OC.w(TAG, "walkFileTreeRandomly, preVisitDirectoryResult != FileVisitResult.CONTINUE"); - } - } else { - Log_OC.d(TAG, "walkFileTreeRandomly, file is not directory"); - visitor.visitFile(start, new BasicFileAttributes(file)); - } - } - } - - private static void insertCustomFolderIntoDB(Path path, - SyncedFolder syncedFolder, - FilesystemDataProvider filesystemDataProvider, - long lastCheck) { - Log_OC.d(TAG, "insertCustomFolderIntoDB called"); - final long enabledTimestampMs = syncedFolder.getEnabledTimestampMs(); - - try { - walkFileTreeRandomly(path, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) { - File file = path.toFile(); - if (syncedFolder.isExcludeHidden() && file.isHidden()) { - Log_OC.w(TAG, "skipping files, exclude hidden file/folder: " + path); - // exclude hidden file or folder - return FileVisitResult.CONTINUE; - } - - if (attrs.lastModifiedTime().toMillis() < lastCheck) { - Log_OC.w(TAG, "skipping files that already checked: " + path); - // skip files that were already checked - return FileVisitResult.CONTINUE; - } - - if (syncedFolder.isExisting() || attrs.lastModifiedTime().toMillis() >= enabledTimestampMs) { - // storeOrUpdateFileValue takes a few ms - // -> Rest of this file check takes not even 1 ms. - filesystemDataProvider.storeOrUpdateFileValue(path.toAbsolutePath().toString(), - attrs.lastModifiedTime().toMillis(), - file.isDirectory(), syncedFolder); - } else { - Log_OC.w(TAG, "skipping files. SynchedFolder not exists or enabledTimestampMs not meeting condition" + path); - } - - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { - if (syncedFolder.isExcludeHidden() && dir.compareTo(Paths.get(syncedFolder.getLocalPath())) != 0 && dir.toFile().isHidden()) { - Log_OC.d(TAG, "skipping hidden path: " + dir.getFileName()); - return FileVisitResult.SKIP_SUBTREE; - } - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFileFailed(Path file, IOException exc) { - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - Log_OC.e(TAG, "Something went wrong while indexing files for auto upload: " + e.getLocalizedMessage()); - } - } - - public static void insertAllDBEntriesForSyncedFolder(SyncedFolder syncedFolder) { + public static void insertAllDBEntriesForSyncedFolder(SyncedFolder syncedFolder, AutoUploadHelper helper) { Log_OC.d(TAG, "insertAllDBEntriesForSyncedFolder, called. ID: " + syncedFolder.getId()); final Context context = MainApp.getAppContext(); @@ -195,8 +81,7 @@ public final class FilesSyncHelper { } else { Log_OC.d(TAG, "inserting other media types: " + mediaType.toString()); FilesystemDataProvider filesystemDataProvider = new FilesystemDataProvider(contentResolver); - Path path = Paths.get(syncedFolder.getLocalPath()); - FilesSyncHelper.insertCustomFolderIntoDB(path, syncedFolder, filesystemDataProvider, lastCheckTimestampMs); + helper.insertCustomFolderIntoDB(syncedFolder, filesystemDataProvider); } Log_OC.d(TAG,"File-sync finished full check for custom folder "+syncedFolder.getLocalPath()+" within "+(System.nanoTime() - startTime)+ "ns"); @@ -213,47 +98,58 @@ public final class FilesSyncHelper { } } - public static void insertChangedEntries(SyncedFolder syncedFolder, - String[] changedFiles) { - Log_OC.d(TAG, "insertChangedEntries, called. ID: " + syncedFolder.getId()); - final ContentResolver contentResolver = MainApp.getAppContext().getContentResolver(); - final FilesystemDataProvider filesystemDataProvider = new FilesystemDataProvider(contentResolver); - for (String changedFileURI : changedFiles){ - String changedFile = getFileFromURI(changedFileURI); - if (syncedFolder.containsTypedFile(changedFile)){ - File file = new File(changedFile); - if (!file.exists()) { - Log_OC.w(TAG, "syncedFolder contains not existing changed file: " + changedFile); - } - filesystemDataProvider.storeOrUpdateFileValue(changedFile, - file.lastModified(),file.isDirectory(), - syncedFolder); - } else { - Log_OC.w(TAG, "syncedFolder not contains typed file, changedFile: " + changedFile); - } - } - } - - private static String getFileFromURI(String uri){ - Log_OC.d(TAG, "getFileFromURI, URI: " + uri); + /** + * 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. + */ + public static boolean insertChangedEntries(SyncedFolder syncedFolder, String[] contentUris) { + Log_OC.d(TAG, "insertChangedEntries, syncedFolderID: " + syncedFolder.getId()); final Context context = MainApp.getAppContext(); + final ContentResolver contentResolver = context.getContentResolver(); + final FilesystemDataProvider filesystemDataProvider = new FilesystemDataProvider(contentResolver); + for (String contentUriString : contentUris) { + if (contentUriString == null) { + Log_OC.w(TAG, "null content uri string"); + return false; + } - Cursor cursor; - int column_index_data; - String filePath = null; + Uri contentUri; + try { + contentUri = Uri.parse(contentUriString); + } catch (Exception e) { + Log_OC.e(TAG, "Invalid URI: " + contentUriString, e); + return false; + } - String[] projection = {MediaStore.MediaColumns.DATA}; + String filePath = UriExtensionsKt.toFilePath(contentUri, context); + if (filePath == null) { + Log_OC.w(TAG, "File path is null"); + return false; + } - cursor = context.getContentResolver().query(Uri.parse(uri), projection, null, null, null, null); + File file = new File(filePath); + if (!file.exists()) { + Log_OC.w(TAG, "syncedFolder contains not existing changed file: " + filePath); + return false; + } - if (cursor != null && cursor.moveToFirst()) { - column_index_data = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); - filePath = cursor.getString(column_index_data); - cursor.close(); - } else { - Log_OC.e(TAG, "cant get file from URI"); + if (!syncedFolder.containsTypedFile(file, filePath)) { + Log_OC.w(TAG, "syncedFolder not contains typed file, changedFile: " + filePath); + return false; + } + + filesystemDataProvider.storeOrUpdateFileValue(filePath, file.lastModified(), file.isDirectory(), syncedFolder); } - return filePath; + + Log_OC.d(TAG, "changed content uris successfully stored"); + + return true; } private static void insertContentIntoDB(Uri uri, SyncedFolder syncedFolder, @@ -325,18 +221,18 @@ public final class FilesSyncHelper { final ConnectivityService connectivityService, final PowerManagementService powerManagementService) { Log_OC.d(TAG, "restartUploadsIfNeeded, called"); - new Thread(() -> FileUploadHelper.Companion.instance().retryFailedUploads( + FileUploadHelper.Companion.instance().retryFailedUploads( uploadsStorageManager, connectivityService, accountManager, - powerManagementService)).start(); + powerManagementService); } public static void scheduleFilesSyncForAllFoldersIfNeeded(Context context, SyncedFolderProvider syncedFolderProvider, BackgroundJobManager jobManager) { Log_OC.d(TAG, "scheduleFilesSyncForAllFoldersIfNeeded, called"); for (SyncedFolder syncedFolder : syncedFolderProvider.getSyncedFolders()) { if (syncedFolder.isEnabled()) { - jobManager.schedulePeriodicFilesSyncJob(syncedFolder.getId()); + jobManager.schedulePeriodicFilesSyncJob(syncedFolder); } } if (context != null) { @@ -346,42 +242,21 @@ public final class FilesSyncHelper { } } - public static void startFilesSyncForAllFolders(SyncedFolderProvider syncedFolderProvider, BackgroundJobManager jobManager, boolean overridePowerSaving, String[] changedFiles) { - Log_OC.d(TAG, "startFilesSyncForAllFolders, called"); + public static void startAutoUploadImmediatelyWithContentUris(SyncedFolderProvider syncedFolderProvider, BackgroundJobManager jobManager, boolean overridePowerSaving, String[] contentUris) { + Log_OC.d(TAG, "startAutoUploadImmediatelyWithContentUris"); for (SyncedFolder syncedFolder : syncedFolderProvider.getSyncedFolders()) { if (syncedFolder.isEnabled()) { - jobManager.startImmediateFilesSyncJob(syncedFolder.getId(),overridePowerSaving,changedFiles); + jobManager.startAutoUploadImmediately(syncedFolder, overridePowerSaving, contentUris); } } } - public static long calculateScanInterval( - SyncedFolder syncedFolder, - ConnectivityService connectivityService, - PowerManagementService powerManagementService - ) { - long defaultInterval = BackgroundJobManagerImpl.DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES * 1000 * 60; - if (!connectivityService.isConnected() || connectivityService.isInternetWalled()) { - return defaultInterval * 2; + public static void startAutoUploadImmediately(SyncedFolderProvider syncedFolderProvider, BackgroundJobManager jobManager, boolean overridePowerSaving) { + Log_OC.d(TAG, "startAutoUploadImmediately"); + for (SyncedFolder syncedFolder : syncedFolderProvider.getSyncedFolders()) { + if (syncedFolder.isEnabled()) { + jobManager.startAutoUploadImmediately(syncedFolder, overridePowerSaving, new String[]{}); + } } - - if ((syncedFolder.isWifiOnly() && !connectivityService.getConnectivity().isWifi())) { - return defaultInterval * 4; - } - - if (powerManagementService.getBattery().getLevel() < 80){ - return defaultInterval * 2; - } - - if (powerManagementService.getBattery().getLevel() < 50){ - return defaultInterval * 4; - } - - if (powerManagementService.getBattery().getLevel() < 20){ - return defaultInterval * 8; - } - - return defaultInterval; } } - diff --git a/app/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java b/app/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java index 5e9493f..5c31855 100644 --- a/app/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java +++ b/app/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java @@ -461,6 +461,7 @@ public final class MimeTypeUtil { MIMETYPE_TO_ICON_MAPPING.put("application/rss+xml", R.drawable.file_code); MIMETYPE_TO_ICON_MAPPING.put("application/rtf", R.drawable.file); MIMETYPE_TO_ICON_MAPPING.put("application/vnd.android.package-archive", R.drawable.file_zip); + MIMETYPE_TO_ICON_MAPPING.put("application/vnd.excalidraw+json", R.drawable.file_whiteboard); MIMETYPE_TO_ICON_MAPPING.put("application/vnd.garmin.tcx+xml", R.drawable.file_location); MIMETYPE_TO_ICON_MAPPING.put("application/vnd.google-earth.kml+xml", R.drawable.file_location); MIMETYPE_TO_ICON_MAPPING.put("application/vnd.google-earth.kmz", R.drawable.file_location); diff --git a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt index cca5cd8..5b1767a 100644 --- a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt +++ b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt @@ -29,6 +29,8 @@ import com.nextcloud.client.preferences.AppPreferencesImpl import com.nextcloud.utils.extensions.getParcelableArgument import com.owncloud.android.R import com.owncloud.android.ui.dialog.StoragePermissionDialogFragment +import com.owncloud.android.utils.PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE +import com.owncloud.android.utils.PermissionUtil.REQUEST_CODE_MANAGE_ALL_FILES import com.owncloud.android.utils.theme.ViewThemeUtils object PermissionUtil { @@ -211,7 +213,7 @@ object PermissionUtil { } @RequiresApi(Build.VERSION_CODES.R) - private fun manifestHasAllFilesPermission(context: Context): Boolean { + fun manifestHasAllFilesPermission(context: Context): Boolean { val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) return packageInfo?.requestedPermissions?.contains(Manifest.permission.MANAGE_EXTERNAL_STORAGE) ?: false } diff --git a/app/src/main/java/com/owncloud/android/utils/theme/CapabilityUtils.java b/app/src/main/java/com/owncloud/android/utils/theme/CapabilityUtils.java index e158af9..90a4930 100644 --- a/app/src/main/java/com/owncloud/android/utils/theme/CapabilityUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/theme/CapabilityUtils.java @@ -81,10 +81,11 @@ public final class CapabilityUtils { public static boolean checkOutdatedWarning(Resources resources, OwnCloudVersion version, - boolean hasExtendedSupport) { + boolean hasExtendedSupport, + boolean hasValidSubscription) { return resources.getBoolean(R.bool.show_outdated_server_warning) && (MainApp.OUTDATED_SERVER_VERSION.isSameMajorVersion(version) || version.isOlderThan(MainApp.OUTDATED_SERVER_VERSION)) - && !hasExtendedSupport; + && !hasExtendedSupport && !hasValidSubscription; } } diff --git a/app/src/main/res/drawable/file_whiteboard.xml b/app/src/main/res/drawable/file_whiteboard.xml new file mode 100644 index 0000000..56ebbef --- /dev/null +++ b/app/src/main/res/drawable/file_whiteboard.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_no_internet.xml b/app/src/main/res/drawable/ic_no_internet.xml new file mode 100644 index 0000000..623ec33 --- /dev/null +++ b/app/src/main/res/drawable/ic_no_internet.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_sync_off.xml b/app/src/main/res/drawable/ic_sync_off.xml new file mode 100644 index 0000000..2f3d628 --- /dev/null +++ b/app/src/main/res/drawable/ic_sync_off.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/layout/account_item.xml b/app/src/main/res/layout/account_item.xml index 5bfb546..3bd3adc 100644 --- a/app/src/main/res/layout/account_item.xml +++ b/app/src/main/res/layout/account_item.xml @@ -78,7 +78,7 @@ android:maxLines="1" android:text="@string/placeholder_filename" android:textAppearance="?android:attr/textAppearanceListItem" - tools:text="Firstname Lastname" /> + tools:text="@string/placeholder_first_name_last_name" /> + tools:text="@string/placeholder_status" /> + tools:text="@string/placeholder_random_link" /> diff --git a/app/src/main/res/layout/account_removal_dialog.xml b/app/src/main/res/layout/account_removal_dialog.xml index 5051881..4fcf4c5 100644 --- a/app/src/main/res/layout/account_removal_dialog.xml +++ b/app/src/main/res/layout/account_removal_dialog.xml @@ -40,14 +40,14 @@ android:layout_height="wrap_content" android:ellipsize="middle" android:textAppearance="@style/TextAppearance.Material3.LabelLarge" - tools:text="Alice Muster" /> + tools:text="@string/placeholder_first_name_last_name" /> + tools:text="@string/placeholder_random_account" /> diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 26b892a..f5cda35 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -34,7 +34,7 @@ android:layout_gravity="bottom" app:labelVisibilityMode="labeled" app:menu="@menu/bottom_navigation_menu" - app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" /> + app:layout_behavior="com.nextcloud.ui.behavior.OnScrollBehavior" /> diff --git a/app/src/main/res/layout/activity_document_scan.xml b/app/src/main/res/layout/activity_document_scan.xml index ad881a5..11e8d9e 100644 --- a/app/src/main/res/layout/activity_document_scan.xml +++ b/app/src/main/res/layout/activity_document_scan.xml @@ -33,7 +33,7 @@ android:layout_marginEnd="@dimen/standard_margin" android:layout_marginBottom="@dimen/standard_margin" android:contentDescription="@string/scan_page" - app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" + app:layout_behavior="com.nextcloud.ui.behavior.OnScrollBehavior" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:srcCompat="@drawable/ic_plus" /> diff --git a/app/src/main/res/layout/activity_edit_image.xml b/app/src/main/res/layout/activity_edit_image.xml index 4b11869..097431a 100644 --- a/app/src/main/res/layout/activity_edit_image.xml +++ b/app/src/main/res/layout/activity_edit_image.xml @@ -10,7 +10,9 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/black"> + xmlns:tools="http://schemas.android.com/tools" + android:background="@color/black" + tools:ignore="Overdraw"> - \ No newline at end of file + diff --git a/app/src/main/res/layout/auto_upload_battery_saver_warning_card.xml b/app/src/main/res/layout/auto_upload_battery_saver_warning_card.xml new file mode 100644 index 0000000..c08ca97 --- /dev/null +++ b/app/src/main/res/layout/auto_upload_battery_saver_warning_card.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conflict_resolve_dialog.xml b/app/src/main/res/layout/conflict_resolve_dialog.xml index 0313b7b..a82faaa 100644 --- a/app/src/main/res/layout/conflict_resolve_dialog.xml +++ b/app/src/main/res/layout/conflict_resolve_dialog.xml @@ -63,13 +63,13 @@ android:id="@+id/left_timestamp" android:layout_width="match_parent" android:layout_height="wrap_content" - tools:text="12. Dec 2020 - 23:10:20" /> + tools:text="@string/placeholder_date" /> + tools:text="@string/placeholder_fileSize_2" /> + tools:text="@string/placeholder_date_2" /> + tools:text="@string/placeholder_fileSize_3" /> diff --git a/app/src/main/res/layout/dialog_sso_grant_permission.xml b/app/src/main/res/layout/dialog_sso_grant_permission.xml index 12ca622..5b66f75 100644 --- a/app/src/main/res/layout/dialog_sso_grant_permission.xml +++ b/app/src/main/res/layout/dialog_sso_grant_permission.xml @@ -43,6 +43,6 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:text="Grant Nextcloud News access to your Nextcloud account incrediblyLong_username_with_123456789_number@Nextcloud_dummy.com?" /> + tools:text="@string/placeholder_sso" /> diff --git a/app/src/main/res/layout/drawer_header.xml b/app/src/main/res/layout/drawer_header.xml index 4c99847..88f7ade 100644 --- a/app/src/main/res/layout/drawer_header.xml +++ b/app/src/main/res/layout/drawer_header.xml @@ -46,8 +46,7 @@ android:textSize="18sp" android:textStyle="bold" android:visibility="gone" - tools:ignore="RtlHardcoded" - tools:text="Nextcloud" /> + tools:ignore="RtlHardcoded" /> diff --git a/app/src/main/res/layout/empty_list.xml b/app/src/main/res/layout/empty_list.xml index 5f1aea4..4bb213e 100644 --- a/app/src/main/res/layout/empty_list.xml +++ b/app/src/main/res/layout/empty_list.xml @@ -43,6 +43,7 @@ android:paddingTop="@dimen/standard_padding" android:paddingBottom="@dimen/standard_half_padding" android:text="@string/file_list_loading" + android:textColor="@color/text_color" android:textSize="26sp" /> diff --git a/app/src/main/res/layout/etm_background_job_list_item.xml b/app/src/main/res/layout/etm_background_job_list_item.xml index b67386e..113b865 100644 --- a/app/src/main/res/layout/etm_background_job_list_item.xml +++ b/app/src/main/res/layout/etm_background_job_list_item.xml @@ -27,7 +27,7 @@ android:id="@+id/etm_background_job_uuid" android:layout_width="match_parent" android:layout_height="wrap_content" - tools:text="d7edb387-0b61-4e4e-a728-ffab3055d700" /> + tools:text="@string/placeholder_uuid" /> + tools:text="@string/placeholder_job_name" /> @@ -64,7 +64,7 @@ android:id="@+id/etm_background_job_user" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:text="user@nextcloud.com" /> + tools:text="@string/placeholder_random_account" /> @@ -82,7 +82,7 @@ android:id="@+id/etm_background_job_state" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:text="ENQUEUED" /> + tools:text="@string/placeholder_job_state" /> @@ -100,7 +100,7 @@ android:id="@+id/etm_background_job_started" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:text="2020-02-15T20:53:15Z" /> + tools:text="@string/placeholder_date_3" /> @@ -119,7 +119,7 @@ android:id="@+id/etm_background_job_progress" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:text="50%" /> + tools:text="@string/placeholder_progress" /> @@ -139,7 +139,7 @@ android:id="@+id/etm_background_execution_count" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:text="0" /> + tools:text="@string/placeholder_random_number" /> diff --git a/app/src/main/res/layout/etm_transfer_list_item.xml b/app/src/main/res/layout/etm_transfer_list_item.xml index 22dd93b..f708b1d 100644 --- a/app/src/main/res/layout/etm_transfer_list_item.xml +++ b/app/src/main/res/layout/etm_transfer_list_item.xml @@ -46,7 +46,7 @@ app:layout_constraintStart_toEndOf="@+id/etm_transfer_type_icon" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" - tools:text="@string/etm_transfer_type_download" /> + tools:text="@string/placeholder_download" /> @@ -65,7 +65,7 @@ android:id="@+id/etm_transfer_uuid" android:layout_width="match_parent" android:layout_height="wrap_content" - tools:text="d7edb387-0b61-4e4e-a728-ffab3055d700" /> + tools:text="@string/placeholder_uuid" /> + tools:text="@string/placeholder_file_path" /> @@ -100,7 +100,7 @@ android:id="@+id/etm_transfer_user" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:text="user@nextcloud.com" /> + tools:text="@string/placeholder_random_account" /> @@ -118,7 +118,7 @@ android:id="@+id/etm_transfer_state" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:text="PENDING" /> + tools:text="@string/placeholder_job_state" /> @@ -137,7 +137,7 @@ android:id="@+id/etm_transfer_progress" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:text="50%" /> + tools:text="@string/placeholder_progress" /> diff --git a/app/src/main/res/layout/file_actions_bottom_sheet_item.xml b/app/src/main/res/layout/file_actions_bottom_sheet_item.xml index 5767260..77b245f 100644 --- a/app/src/main/res/layout/file_actions_bottom_sheet_item.xml +++ b/app/src/main/res/layout/file_actions_bottom_sheet_item.xml @@ -46,7 +46,7 @@ android:layout_marginStart="@dimen/bottom_sheet_text_start_margin" android:textColor="@color/text_color" android:textSize="@dimen/bottom_sheet_text_size" - tools:text="Delete file" /> + tools:text="@string/placeholder_delete_file" /> diff --git a/app/src/main/res/layout/file_details_share_group.xml b/app/src/main/res/layout/file_details_share_group.xml index 1831d49..075e5ba 100644 --- a/app/src/main/res/layout/file_details_share_group.xml +++ b/app/src/main/res/layout/file_details_share_group.xml @@ -49,7 +49,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="@dimen/two_line_primary_text_size" - tools:text="@string/share_internal_link_to_folder_text" /> + tools:text="@string/placeholder_share_internal_link_text" /> diff --git a/app/src/main/res/layout/file_details_share_internal_share_link.xml b/app/src/main/res/layout/file_details_share_internal_share_link.xml index 1831d49..8bc5eb8 100644 --- a/app/src/main/res/layout/file_details_share_internal_share_link.xml +++ b/app/src/main/res/layout/file_details_share_internal_share_link.xml @@ -49,7 +49,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="@dimen/two_line_primary_text_size" - tools:text="@string/share_internal_link_to_folder_text" /> + tools:text="@string/placeholder_share_internal_folder_text" /> diff --git a/app/src/main/res/layout/file_details_share_link_share_item.xml b/app/src/main/res/layout/file_details_share_link_share_item.xml index ea32fd5..e7cf425 100644 --- a/app/src/main/res/layout/file_details_share_link_share_item.xml +++ b/app/src/main/res/layout/file_details_share_link_share_item.xml @@ -56,7 +56,7 @@ android:gravity="center_vertical" android:visibility="gone" android:singleLine="true" - tools:text="5 downloads remaining" + tools:text="@string/placeholder_remaining_downloads" tools:visibility="visible" android:textColor="@color/text_color" android:textSize="@dimen/two_line_primary_text_size" /> @@ -67,7 +67,7 @@ android:layout_height="wrap_content" android:gravity="center_vertical" android:singleLine="true" - tools:text="View only" + tools:text="@string/placeholder_view_only" app:drawableEndCompat="@drawable/ic_baseline_arrow_drop_down_24" app:drawableTint="@color/primary" app:drawableRightCompat="@drawable/ic_baseline_arrow_drop_down_24" diff --git a/app/src/main/res/layout/file_details_share_share_item.xml b/app/src/main/res/layout/file_details_share_share_item.xml index 054c6e4..facdb0c 100644 --- a/app/src/main/res/layout/file_details_share_share_item.xml +++ b/app/src/main/res/layout/file_details_share_share_item.xml @@ -60,7 +60,7 @@ app:drawableEndCompat="@drawable/ic_baseline_arrow_drop_down_24" app:drawableRightCompat="@drawable/ic_baseline_arrow_drop_down_24" app:drawableTint="@color/primary" - tools:text="View only" /> + tools:text="@string/placeholder_view_only" /> + app:layout_behavior="com.nextcloud.ui.behavior.OnScrollBehavior" /> diff --git a/app/src/main/res/layout/first_run_activity.xml b/app/src/main/res/layout/first_run_activity.xml index a9a44b6..369bb3f 100644 --- a/app/src/main/res/layout/first_run_activity.xml +++ b/app/src/main/res/layout/first_run_activity.xml @@ -8,6 +8,7 @@ --> @@ -24,7 +25,8 @@ android:id="@+id/contentPanel" android:layout_width="match_parent" android:layout_height="0dp" - android:layout_weight="1"/> + android:layout_weight="1" + tools:ignore="NestedWeights" /> + tools:text="@string/placeholder_month" /> + tools:text="@string/placeholder_year" /> diff --git a/app/src/main/res/layout/info_box.xml b/app/src/main/res/layout/info_box.xml index d7ed508..8541f5f 100644 --- a/app/src/main/res/layout/info_box.xml +++ b/app/src/main/res/layout/info_box.xml @@ -29,6 +29,6 @@ android:paddingRight="@dimen/standard_half_padding" android:paddingStart="@dimen/standard_half_margin" android:textColor="@color/standard_grey" - tools:text="@string/offline_mode" /> + tools:text="@string/placeholder_share_no_internet_connection" /> diff --git a/app/src/main/res/layout/internal_two_way_sync_view_holder.xml b/app/src/main/res/layout/internal_two_way_sync_view_holder.xml index 680b921..ba949f3 100644 --- a/app/src/main/res/layout/internal_two_way_sync_view_holder.xml +++ b/app/src/main/res/layout/internal_two_way_sync_view_holder.xml @@ -38,7 +38,7 @@ android:singleLine="true" android:textColor="@color/text_color" android:textSize="@dimen/two_line_primary_text_size" - tools:text="Folder abc" /> + tools:text="@string/placeholder_filename" /> + tools:text="@string/placeholder_fileSize_2" /> + tools:text="@string/placeholder_time_ago" /> + tools:text="@string/placeholder_success" /> diff --git a/app/src/main/res/layout/list_header_open_in.xml b/app/src/main/res/layout/list_header_open_in.xml index 74b907b..10401f3 100644 --- a/app/src/main/res/layout/list_header_open_in.xml +++ b/app/src/main/res/layout/list_header_open_in.xml @@ -39,7 +39,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="@dimen/standard_quarter_margin" - tools:text="This folder is best viewed in the Notes app" /> + tools:text="@string/placeholder_open_in_notes_info" /> @@ -49,7 +49,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="@dimen/standard_quarter_margin" - tools:text="Open in Notes" /> + tools:text="@string/placeholder_open_in_notes" /> + app:ensureMinTouchTargetSize="false" /> + app:ensureMinTouchTargetSize="false" /> + app:ensureMinTouchTargetSize="false" /> diff --git a/app/src/main/res/layout/material_list_item_single_line.xml b/app/src/main/res/layout/material_list_item_single_line.xml index 535362d..a0da69f 100644 --- a/app/src/main/res/layout/material_list_item_single_line.xml +++ b/app/src/main/res/layout/material_list_item_single_line.xml @@ -25,7 +25,7 @@ android:lines="1" android:ellipsize="end" android:textColor="?android:attr/textColorPrimary" - tools:text="Single line of text"/> + tools:text="@string/placeholder_filename"/> @@ -63,8 +63,7 @@ + android:importantForAutofill="no"> + tools:text="@string/placeholder_predefined_status_icon" /> + tools:text="@string/placeholder_predefined_status_name" /> + tools:text="@string/placeholder_predefined_status_clear_at" /> diff --git a/app/src/main/res/layout/preview_image_details_fragment.xml b/app/src/main/res/layout/preview_image_details_fragment.xml index f9b3ad6..72f0017 100644 --- a/app/src/main/res/layout/preview_image_details_fragment.xml +++ b/app/src/main/res/layout/preview_image_details_fragment.xml @@ -54,14 +54,14 @@ android:layout_height="wrap_content" android:padding="2dp" android:textStyle="bold" - tools:text="Wednesday • 26 Jul 2023 • 12:27" /> + tools:text="@string/placeholder_image_detail_date" /> + tools:text="@string/placeholder_image_details" /> @@ -95,14 +95,14 @@ android:layout_height="wrap_content" android:padding="2dp" android:textStyle="bold" - tools:text="Camera Phone (4th generation)" /> + tools:text="@string/placeholder_image_detail_model" /> + tools:text="@string/placeholder_image_detail_condition" /> @@ -133,7 +133,7 @@ android:textAlignment="center" android:textStyle="bold" android:visibility="gone" - tools:text="Mitte, Berlin, Germany" + tools:text="@string/placeholder_image_detail_location" tools:visibility="visible" /> + tools:text="@string/placeholder_image_detail_example_copyright" /> diff --git a/app/src/main/res/layout/profile_bottom_sheet_action.xml b/app/src/main/res/layout/profile_bottom_sheet_action.xml index 097bd1c..17078fd 100644 --- a/app/src/main/res/layout/profile_bottom_sheet_action.xml +++ b/app/src/main/res/layout/profile_bottom_sheet_action.xml @@ -31,7 +31,7 @@ android:layout_gravity="center_vertical" android:paddingStart="24dp" android:paddingEnd="0dp" - tools:text="Compose email" + tools:text="@string/placeholder_compose_mail" android:textColor="@color/text_color" android:textSize="@dimen/bottom_sheet_text_size" /> diff --git a/app/src/main/res/layout/profile_bottom_sheet_fragment.xml b/app/src/main/res/layout/profile_bottom_sheet_fragment.xml index 5f0a6ef..06c8927 100644 --- a/app/src/main/res/layout/profile_bottom_sheet_fragment.xml +++ b/app/src/main/res/layout/profile_bottom_sheet_fragment.xml @@ -38,7 +38,7 @@ android:layout_gravity="center_vertical" android:paddingTop="@dimen/standard_padding" android:paddingBottom="@dimen/standard_padding" - tools:text="Christine Scott" + tools:text="@string/placeholder_first_name_last_name" android:textColor="@color/text_color" android:textSize="@dimen/bottom_sheet_text_size" /> diff --git a/app/src/main/res/layout/send_button.xml b/app/src/main/res/layout/send_button.xml index 25f2ef2..539fe52 100644 --- a/app/src/main/res/layout/send_button.xml +++ b/app/src/main/res/layout/send_button.xml @@ -32,5 +32,5 @@ android:gravity="center_horizontal" android:paddingTop="@dimen/standard_half_padding" android:textColor="@color/text_color" - tools:text="@string/app_name" /> + tools:text="@string/placeholder_send_button" /> diff --git a/app/src/main/res/layout/setup_encryption_dialog.xml b/app/src/main/res/layout/setup_encryption_dialog.xml index 66a8403..61702a8 100644 --- a/app/src/main/res/layout/setup_encryption_dialog.xml +++ b/app/src/main/res/layout/setup_encryption_dialog.xml @@ -20,7 +20,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="@dimen/standard_margin" - tools:text="@string/end_to_end_encryption_keywords_description" /> + tools:text="@string/placeholder_share_internal_e2ee_keyword_description" /> + tools:text="@string/placeholder_file_path" /> diff --git a/app/src/main/res/layout/synced_folders_footer.xml b/app/src/main/res/layout/synced_folders_footer.xml index 8ef6727..ec40a61 100644 --- a/app/src/main/res/layout/synced_folders_footer.xml +++ b/app/src/main/res/layout/synced_folders_footer.xml @@ -21,7 +21,7 @@ android:layout_marginBottom="@dimen/min_list_item_size" android:gravity="center" android:padding="@dimen/standard_padding" - tools:text="Show 3 hidden folders" + tools:text="@string/placeholder_show_hidden_folders" android:textColor="@color/secondary_text_color" /> diff --git a/app/src/main/res/layout/synced_folders_item_header.xml b/app/src/main/res/layout/synced_folders_item_header.xml index 642276c..d4b9a81 100644 --- a/app/src/main/res/layout/synced_folders_item_header.xml +++ b/app/src/main/res/layout/synced_folders_item_header.xml @@ -2,91 +2,120 @@ - - + + + + + android:ellipsize="middle" + android:gravity="center|start" + android:textColor="?android:textColorPrimary" + android:textStyle="bold" + tools:text="@string/placeholder_filename" + android:maxLines="1" + app:layout_constraintBottom_toTopOf="@+id/scanIndicatorText" + app:layout_constraintStart_toEndOf="@+id/type" /> - - - - - - - + android:layout_marginBottom="@dimen/standard_quarter_margin" + android:ellipsize="end" + android:gravity="center|start" + android:maxLines="1" + android:textColor="@color/log_level_warning" + android:textStyle="normal" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toEndOf="@+id/type" + tools:text="@string/placeholder_scan_indicator" + tools:visibility="visible" /> - + - + - - - + + diff --git a/app/src/main/res/layout/synced_folders_layout.xml b/app/src/main/res/layout/synced_folders_layout.xml index ea30c5c..48427af 100644 --- a/app/src/main/res/layout/synced_folders_layout.xml +++ b/app/src/main/res/layout/synced_folders_layout.xml @@ -39,10 +39,6 @@ android:scrollbars="vertical" android:visibility="visible" /> - - + + + diff --git a/app/src/main/res/layout/synced_folders_settings_layout.xml b/app/src/main/res/layout/synced_folders_settings_layout.xml index d0e0e42..c8850c4 100644 --- a/app/src/main/res/layout/synced_folders_settings_layout.xml +++ b/app/src/main/res/layout/synced_folders_settings_layout.xml @@ -41,7 +41,7 @@ android:ellipsize="middle" android:maxLines="2" android:textColor="?android:attr/textColorSecondary" - tools:text="For /storage/emulated/0/DCIM/Camera" /> + tools:text="@string/placeholder_auto_upload_overview_path" /> + tools:text="@string/placeholder_template" /> diff --git a/app/src/main/res/layout/toolbar_standard.xml b/app/src/main/res/layout/toolbar_standard.xml index 5b8e23e..b449700 100644 --- a/app/src/main/res/layout/toolbar_standard.xml +++ b/app/src/main/res/layout/toolbar_standard.xml @@ -200,7 +200,7 @@ app:layout_constraintLeft_toRightOf="@id/menu_button" app:layout_constraintRight_toLeftOf="@id/notification_button" app:layout_constraintTop_toTopOf="parent" - tools:text="Search in Nextcloud" /> + tools:text="@string/placeholder_search_in_nextcloud" /> + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/unified_search_footer.xml b/app/src/main/res/layout/unified_search_footer.xml index d9535df..f4a9602 100755 --- a/app/src/main/res/layout/unified_search_footer.xml +++ b/app/src/main/res/layout/unified_search_footer.xml @@ -25,8 +25,7 @@ android:paddingStart="@dimen/standard_quarter_padding" android:paddingEnd="0dp" android:text="@string/load_more_results" - android:textColor="@color/secondary_text_color" - tools:text="Load more results"> + android:textColor="@color/secondary_text_color"> diff --git a/app/src/main/res/layout/unified_search_header.xml b/app/src/main/res/layout/unified_search_header.xml index fdd8363..f37cd7c 100755 --- a/app/src/main/res/layout/unified_search_header.xml +++ b/app/src/main/res/layout/unified_search_header.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:ellipsize="middle" android:paddingHorizontal="@dimen/standard_padding" - android:paddingVertical="@dimen/standard_half_padding" + android:paddingVertical="@dimen/standard_padding" android:textColor="@color/color_accent" - tools:text="Files" /> + tools:text="@string/placeholder_files" /> diff --git a/app/src/main/res/layout/unified_search_item.xml b/app/src/main/res/layout/unified_search_item.xml index 396cc11..9f65a55 100755 --- a/app/src/main/res/layout/unified_search_item.xml +++ b/app/src/main/res/layout/unified_search_item.xml @@ -80,7 +80,7 @@ android:text="" android:textColor="@color/text_color" android:textSize="@dimen/two_line_primary_text_size" - tools:text="Test 123" /> + tools:text="@string/placeholder_filename" /> + tools:text="@string/placeholder_in_folder" /> diff --git a/app/src/main/res/layout/upload_file_dialog.xml b/app/src/main/res/layout/upload_file_dialog.xml index ca1e0ba..aeeb380 100755 --- a/app/src/main/res/layout/upload_file_dialog.xml +++ b/app/src/main/res/layout/upload_file_dialog.xml @@ -20,7 +20,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/upload_file_dialog_filename" - tools:text="@string/upload_file_dialog_filename"/> + tools:text="@string/placeholder_filename"/> + tools:text="@string/placeholder_file_type"/> - + tools:text="@string/placeholder_current_2" /> + android:src="@drawable/ic_delete" + app:tint="#757575" /> + + diff --git a/app/src/main/res/layout/user_info_details_table_item.xml b/app/src/main/res/layout/user_info_details_table_item.xml index 60257d6..c53b416 100644 --- a/app/src/main/res/layout/user_info_details_table_item.xml +++ b/app/src/main/res/layout/user_info_details_table_item.xml @@ -37,6 +37,6 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/icon" app:layout_constraintTop_toTopOf="parent" - tools:text="+49 123 456 789 12" /> + tools:text="@string/placeholder_example_phone_number" /> diff --git a/app/src/main/res/layout/user_info_layout.xml b/app/src/main/res/layout/user_info_layout.xml index 5a7d442..fb29fe8 100644 --- a/app/src/main/res/layout/user_info_layout.xml +++ b/app/src/main/res/layout/user_info_layout.xml @@ -62,7 +62,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/userinfo_icon" app:layout_constraintTop_toTopOf="@id/userinfo_icon" - tools:text="John Doe" /> + tools:text="@string/placeholder_first_name_last_name" /> + tools:text="@string/placeholder_example_mail" /> diff --git a/app/src/main/res/layout/version_list_item.xml b/app/src/main/res/layout/version_list_item.xml index 3dd1541..dad1ef3 100644 --- a/app/src/main/res/layout/version_list_item.xml +++ b/app/src/main/res/layout/version_list_item.xml @@ -45,7 +45,7 @@ android:layout_height="0dp" android:layout_weight="1" android:ellipsize="end" - tools:text="256 KB" + tools:text="@string/placeholder_fileSize" android:textColor="?android:attr/textColorSecondary"/> @@ -67,7 +67,7 @@ android:layout_weight="1" android:ellipsize="end" android:textAlignment="textEnd" - tools:text="13:24" + tools:text="@string/placeholder_media_time" android:textColor="?android:attr/textColorSecondary"/> diff --git a/app/src/main/res/layout/whats_new_activity.xml b/app/src/main/res/layout/whats_new_activity.xml index e30e408..b85385e 100644 --- a/app/src/main/res/layout/whats_new_activity.xml +++ b/app/src/main/res/layout/whats_new_activity.xml @@ -7,7 +7,8 @@ ~ SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) --> @@ -51,7 +52,8 @@ android:gravity="center" android:paddingLeft="0dp" android:paddingRight="0dp" - android:text="@string/whats_new_skip"/> + android:text="@string/whats_new_skip" + tools:ignore="NestedWeights" /> + tools:text="@string/placeholder_widget_first_line" /> + tools:text="@string/placeholder_widget_second_line" /> diff --git a/app/src/main/res/layout/widget_list_item.xml b/app/src/main/res/layout/widget_list_item.xml index f2bc008..0b6b66d 100644 --- a/app/src/main/res/layout/widget_list_item.xml +++ b/app/src/main/res/layout/widget_list_item.xml @@ -29,5 +29,5 @@ android:layout_height="32dp" android:layout_weight="1" android:gravity="center_vertical" - tools:text="Widget name" /> + tools:text="@string/placeholder_first_name_last_name" /> diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8bdf6de..3e09e31 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -544,7 +544,6 @@ قد تستغرق هذه العملية بعض الوقت. إدارة المساحة - تجاوزت الحد الأقصى المسموح به لزمن رفع الملفات. رجاءً، لا تقم برفع عدد يزيد عن 500 ملف في كل مرة. ملف الوسائط لا يمكن بثه تعذرت قراءة ملف الوسائط يحتوي ملف الوسائط على ترميز غير صحيح @@ -642,12 +641,6 @@ لم يتم العثور على تطبيق لتعيين صورة به ثبِّت في الشاشة الرئيسية فتح %1$s - .txt - 389 ك.ب - 12:23:45 - تغيرت مؤخرا - هذه مساحة محجوزة - 2012/05/18 12:23 مساء إيقاف تبديل من فضلك، قم بتحديد الخادم... @@ -668,6 +661,7 @@ حول تفاصيل التطوير + الملفّات عام المزيد زامن @@ -852,6 +846,8 @@ التسجيل عبر مزوّد خدمة هل تسمح لـ%1$s بالوصول إلى حسابك %2$s في Nextcloud؟ ترتيب + فرز المفضلة أولا + فرز المجلدات قبل الملفات إخفاء تفاصيل لا يمكن التحقق من هوية الخادم diff --git a/app/src/main/res/values-ast/strings.xml b/app/src/main/res/values-ast/strings.xml index b8df4e2..bce3f1d 100644 --- a/app/src/main/res/values-ast/strings.xml +++ b/app/src/main/res/values-ast/strings.xml @@ -382,12 +382,6 @@ Negar Ríquense permisos adicionales pa xubir y baxar ficheros. Abrir «%1$s» - .txt - 389 KB - 12:23:45 - Editóse apocayá - Esto ye un marcador de posición - 2012/05/18 12:23 PM desanicióse guardáu en carpeta orixinal movíu a la carpeta d\'aplicaciones @@ -399,6 +393,7 @@ Amestar cuenta Tocante a Detalles + Ficheros Xeneral Más Sincronizar @@ -492,6 +487,8 @@ Rexistrase con un fornidor ¿Quies permitr que %1$s acceda a la cuenta de Nextcloud %2$s? Ordenar per + Ordenar los favoritos primero + Ordenar les carpetes enantes que los ficheros Esconder Detalles Nun se pudo verificar la identidá del sirvidor diff --git a/app/src/main/res/values-b+en+001/strings.xml b/app/src/main/res/values-b+en+001/strings.xml index e88e7af..71cb8a8 100644 --- a/app/src/main/res/values-b+en+001/strings.xml +++ b/app/src/main/res/values-b+en+001/strings.xml @@ -44,6 +44,7 @@ Shows one widget from dashboard Search in %s Appear offline + This content was generated by AI and can make mistakes. Add new task Create a new task from bottom right Type some text @@ -55,6 +56,7 @@ An error occurred while deleting the task Task successfully deleted Task list is empty. + Task list is empty. Check assistant app configuration. Unable to fetch task list, please check your internet connection. Delete Task The task output is not ready yet. @@ -93,12 +95,19 @@ %1$s does not support multiple accounts Could not establish connection Cancel Login + Please enter a valid server address. + Unable to fetch login details. Please try again. There was an issue processing your login request. Please try again later. + No browser is available to open this link. Please complete login process in your browser + Auto-upload is paused because Battery Saver is on. kept in original folder, as it is readonly + Low battery, upload might take longer Only upload on unmetered Wi-Fi /AutoUpload This folder is already included in the parent folder’s sync, which may cause duplicate uploads + Waiting for Wi-Fi to start uploading + Uploading files from %s to %s Configure Create new custom folder setup Set up a custom folder @@ -202,6 +211,7 @@ Import failed to start. Please try again No file found Could not find your last backup! + Detecting content changes Copied to clipboard An error occurred while trying to copy this file or folder It is not possible to copy a folder into one of its own underlying folders @@ -407,6 +417,8 @@ No results found for your query Start your search Type in the search bar above to find files, contacts, calendar events, and more across your account. + Check your Internet connection or try again later + Poor connection folder LIVE Loading… @@ -471,6 +483,11 @@ Folder already exists This folder is best viewed in %1$s. Create + %1$d of %2$d · %3$s + An error occurred during synchronisation of the %s folder + Insufficient disk space, synchronisation cancelled + %s folder successfully synchronised + Syncing… No folders here Folder name cannot be empty Choose @@ -558,7 +575,6 @@ Clear data Settings, database and server certificates from %1$s\'s data will be deleted permanently. \n\nDownloaded files will be kept untouched.\n\nThis process can take a while. Manage space - You have reached the maximum file upload limit. Please upload fewer than 500 files at a time. The media file cannot be streamed Could not read the media file The media file has incorrect encoding @@ -607,6 +623,8 @@ Failed to execute action. Show notifications to interact result of background operations Background operations + Detects local file changes + Content observer Shows download progress Downloads Shows file sync progress and results @@ -647,6 +665,7 @@ Enter your passcode The passcode will be requested every time the app is started Please enter your passcode + The passcode will be requested every time the app is opened or reopened after 5 seconds. The passcodes are not the same Please reenter your passcode Delete your passcode @@ -662,13 +681,6 @@ No app found to set a picture with Pin to home screen Open %1$s - .txt - 389 KB - placeholder - 12:23:45 - Recently edited - This is a placeholder - 2012/05/18 12:23 PM stop toggle Please select a server… @@ -690,6 +702,7 @@ About Details Dev + Files General More Sync @@ -881,6 +894,8 @@ Sign up with provider Allow %1$s to access your Nextcloud account %2$s? Sort by + Sort favourites first + Sort folders before files Hide Details The identity of the server could not be verified @@ -991,6 +1006,7 @@ Event not found, you can always sync to update. Redirecting to web… Contact not found, you can always sync to update. Redirecting to web… Permissions are required to open search result otherwise it will redirected to web… + In this folder Unknown Unlock file Unread comments exist @@ -1098,6 +1114,8 @@ Untrusted server certificate Fetching server version… App terminated + Skipped + A file with the same name already exists. Completed Same file found on remote, skipping upload Unknown error @@ -1191,6 +1209,10 @@ %d file will be exported. See notification for details. %d files will be exported. See notification for details. + + You can upload only %d file at once. + You can upload up to %d files at once. + %1$d folder %1$d folders diff --git a/app/src/main/res/values-bg-rBG/strings.xml b/app/src/main/res/values-bg-rBG/strings.xml index 6c84e0a..7403658 100644 --- a/app/src/main/res/values-bg-rBG/strings.xml +++ b/app/src/main/res/values-bg-rBG/strings.xml @@ -43,6 +43,7 @@ Търсене в %s Изтрива задача Задачата е успешно изтрита + Асистент Свързания профил не е намерен! Достъп неуспешен: %1$s Профилът все още не съществува на устройството @@ -88,6 +89,7 @@ Изключване Възможно е устройството ви да има активирана оптимизация на батерията. AutoUpload работи правилно само, ако изключите това приложение от него. Оптимизация на батерията + Асистент Любими Всички файлове Зает @@ -119,6 +121,7 @@ Грешка Няма достатъчно памет Неизвестна грешка + Напускане на споделянето Зареждане… Следващ Не @@ -234,6 +237,7 @@ Фоново изображение на заглавката на чекмедже/панел/ Активност Всички файлове + Асистент Любими Медия Начало @@ -250,6 +254,7 @@ %1$s използвани Автоматично качване E2E все още няма настройки + Асистент Повече Включване на криптиране Настройки за криптиране @@ -524,12 +529,6 @@ Изискват се допълнителни права за изтегляне и сваляне на файлове. Няма намерени приложения за задаване на снимка Отваряне на %1$s - .txt - 389 KB - 12:23:45 - Наскоро редактирани - Това е за запазено място - 2012/05/18 12:23 PM стоп превключване Деактивирането на проверката за пестене на енергия може да доведе до качване на файлове, в състояние с ниска батерия! @@ -547,6 +546,7 @@ Относно Подробности Разработка + Файлове Общи Още Синхронизиране @@ -694,6 +694,8 @@ Абониране при доставчик Разрешавате ли на %1$s да достъпва вашия Nextcloud профил %2$s? Сортиране по + Първо сортирайте любимите + Сортиране на папки преди файлове Скриване Подробности Идентичността на сървъра не може да бъде потвърдена @@ -824,6 +826,7 @@ Локалното място за съхранение е пълно Файлът не може да бъде копиран в локалното хранилище Неуспешно заключване на папката + Качването е отменено от потребителя Шифроването е възможно само с > = Android 5.0 В папка %1$s няма достатъчно място за копиране на избраните файлове. Желаете ли да бъдат преместени? Сканиране на документ от камера diff --git a/app/src/main/res/values-br/strings.xml b/app/src/main/res/values-br/strings.xml index ffe0b47..7e14f88 100644 --- a/app/src/main/res/values-br/strings.xml +++ b/app/src/main/res/values-br/strings.xml @@ -510,11 +510,6 @@ Difennet Aotreoù ouzhpenn ez eus ezhomp evit pellkas ha pellgargañ restroù. Meziant ebet kavet evit lakaat ur skeudenn gant - 389 KB - 12:23:45 - Cheñchet n\'eus ket pell zo - Ur lec\'h miret eo - 2012/05/18 12:23 PM Disaotreañ gwiriañ ar saver pod-tredañ a c\'hel lezel an ardivink pellkargañ restroù pa ne vez ket kalz a dredañ kenn ! dilamet gwarn ar restr orin diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 411b1de..9c75124 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -141,7 +141,7 @@ Error Memòria insuficient Error desconegut - Abandona aquest element compartit + Abandona aquest compartició S\'està carregant… Següent No @@ -215,7 +215,7 @@ Carpeta nova Nova presentació Nou full de càlcul - Afegiu una descripció per a la carpeta + Afegeix una descripció per a la carpeta Afegeix la descripció de la carpeta Credencials inhabilitades Còpia de seguretat diària @@ -233,7 +233,7 @@ No s\'han verificat els duplicats. Aquest algorisme de resum no és disponible al vostre telèfon. l\'Inici de sessió via enllaç directe ha fallat! - Inicia la sessió amb %1$s a %2$s + Inici de sessió amb %1$s a %2$s Inhabilita Descarta Descarta la notificació @@ -443,7 +443,7 @@ La carpeta ja existeix Crea No hi ha carpetes - El nom del fitxer no pot estar buit + El nom de la carpeta no pot estar buit Tria Trieu la carpeta de destinació Còpia @@ -463,7 +463,7 @@ Endavant 4 hores Google ha restringit la baixada de fitxers APK/AAB! - Aquesta icona indica la disponibilitat de la foto en directe + Aquesta icona indica la disponibilitat de la foto en viu El nom donarà com a resultat un fitxer ocult Nom Nota @@ -528,7 +528,6 @@ Neteja dades Els paràmetres, la base de dades i els certificats del servidor %1$s se suprimiran de forma permanent. \n\nEls fitxers descarregats romandran intactes.\n\n Aquest procés pot trigar una estona. Gestió d\'espai - Has assolit el límit màxim de pujada de fitxers. Puja menys de 500 fitxers alhora. No es pot reproduir el fitxer multimèdia No s\'ha pogut llegir el fitxer multimèdia El fitxer multimèdia té una codificació incorrecta @@ -621,12 +620,6 @@ No s\'ha trobat cap aplicació per establir-hi la foto Fixa-ho a la pantalla d\'inici Obre %1$s - .txt - 389 KB - 12:23:45 - Editat recentment - Això és un marcador de posició - 2012/05/18 12:23 PM atura commuta Seleccioneu un servidor… @@ -647,13 +640,14 @@ Quant a Detalls Dev + Fitxers General Més Sincronitza Còpia de seguretat diària del calendari i els contactes Còpia de seguretat diària dels vostres contactes Ubicació de l\'emmagatzematge de dades - Gestioneu la ubicació de l\'emmagatzematge de dades + Administrar la ubicació de l\'emmagatzematge de dades S\'ha configurat el xifratge d\'extrem a extrem E2E mnemònic Per mostrar els mnemònics, si us plau habiliteu les credencials del dispositiu. @@ -809,6 +803,8 @@ Registreu-vos amb el proveïdor Voleu permetre que %1$s accedeixi al vostre compte de Nextcloud %2$s? Ordena per + Ordena primer els preferits + Ordena les carpetes abans dels fitxers Amaga Detalls No s\'ha pogut verificar la identitat del servidor @@ -858,7 +854,7 @@ \"%1$s\" us ha estat compartit %1$s us ha compartit %2$s Només fotografies - Fotos i vídeos + Fotografies i vídeos Només vídeos Suggereix Sincronitza @@ -999,7 +995,7 @@ Completat Error desconegut S\'ha detectat un virus. No es pot completar la pujada! - S\'està esperant al mode d\'apagada segur + S\'està esperant a sortir del mode d\'estalvi d\'energia S\'està esperant càrrega de bateria S\'està esperant una wifi sense mètrica de consum de dades Usuari diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index f844709..ff1cd6a 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -44,6 +44,7 @@ Zobrazuje jeden ovládací prvek z nástěnky Hledat v %s Jevit se offline + Tento obsah byl vytvořen pomocí AI a může obsahovat chyby. Přidat nový úkol Z pravého dolního rohu vytvořte nový úkol Zadejte nějaký text @@ -55,6 +56,7 @@ Při mazání úlohy se vyskytla chyba Úloha úspěšně smazána Seznam úkolů je prázdný. + Seznam úkolů je prázdný. Zkontrolujte nastavení aplikace Asistent. Nedaří se získat seznam úloh – zkontrolujte připojení k Internetu. Smazat úlohu Výstup z úkolu ještě není připraven. @@ -93,12 +95,19 @@ %1$s nepodporuje vícero účtů Nedaří se navázat spojení Zrušit přihlášení + Zadejte platnou adresu serveru. + Nebylo možné získat podrobnosti o přihlášení. Zkuste to znovu. Došlo k problému při zpracovávání vašeho požadavku na přihlášení se. Zkuste to prosím později. + Není k dispozici žádný webový prohlížeč pro otevření tohoto odkazu. Dokončete proces přihlášení prostřednictvím webového prohlížeče + Automatické nahrávání je pozastaveno, protože je zapnuté šetření energií z akumulátoru. ponechán v původní složce, protože je pouze pro čtení + Téměř vybitý akumulátor – nahrávání může trvat déle Nahrávat pouze přes neúčtované Wi-Fi připojení /AutoUpload Tato složka už je obsažena v synchronizaci nadřazené složky, což může způsobovat duplicitní nahrávání + Čeká se na Wi-Fi, aby bylo možné spustit nahrávání + Jsou nahrávány soubory z %s do %s Nastavit Vytvořit nové uživatelsky určené nastavení složky Nastavit uživatelsky určenou složku @@ -202,6 +211,7 @@ Import se nepodařilo spustit. Zkuste to znovu Nenalezen žádný soubor Nepodařilo se najít vaši nejaktuálnější zálohu! + Zjišťování změn obsahu Zkopírováno do schránky Při pokusu o zkopírování tohoto souboru či složky došlo k chybě Není možné zkopírovat složku do některé z jejích vlastních podsložek @@ -407,6 +417,8 @@ Na váš dotaz nic nenalezeno Zahájit vaše vyhledávání Pište do pruhu vyhledávání výše a hledejte v souborech, kontaktech, událostech v kalendáři a dalším napříč vaším účtem. + Zkontrolujte své připojení k Internetu nebo to zkuste znovu + Nekvalitní připojení složka ŽIVĚ Načítání… @@ -471,6 +483,11 @@ Složka už existuje Tuto složku je nejlépe prohlížet si v %1$s. Vytvořit + %1$d z %2$d · %3$s + Při synchronizování složky %s došlo k chybě + Nedostatek prostoru na disku – synchronizace zrušena + složka %s úspěšně synchronizována + Synchronizování… Nejsou zde žádné složky Název složky je třeba vyplnit Vybrat @@ -558,7 +575,6 @@ Vyčistit data Nastavení, databáze a certifikáty serverů z dat %1$s budou natrvalo smazány. \n\nStažené soubory zůstanou beze změny.\n\nTento proces může chvíli trvat. Spravovat úložný prostor - Dosáhli jste maximálního limitu počtu nahrávaných souborů. Nahrávejte méně než 500 souborů naráz. Soubor s médii se nedaří proudově vysílat (stream) Nepodařilo se číst soubor média Soubor médií nemá platné kódování @@ -607,6 +623,8 @@ Akci se nepodařilo provést. Zobrazovat upozornění pro interakci s výsledkem operací na pozadí Operace na pozadí + Zjišťovat změny v lokálních souborech + Pozorovatel obsahu Zobrazuje ukazatel postupu stahování Stažené Zobrazuje průběh a výsledek synchronizace souborů @@ -647,6 +665,7 @@ Zadejte svůj bezpečnostní kód Bezpečnostní kód bude vyžadován při každém spuštění aplikace Zadejte svůj bezpečnostní kód + Bezpečnostní kód bude vyžadován při každém spuštění aplikace nebo jejím znovuotevření za déle než 5 sekund. Zadání bezpečnostního kódu se neshodují Zopakujte zadání svého bezpečnostního kódu Smazat váš bezpečnostní kód @@ -662,13 +681,6 @@ Nenalezena žádná aplikace pro nastavení obrázku Připnout na domovskou obrazovku Otevřít %1$s - .txt - 389 KB - výplň - 12:23:45 - Nedávno upravováno - Toto je zástupný text - 2012/05/18 12:23 vypnout přepnout Vyberte server… @@ -690,6 +702,7 @@ O aplikaci Podrobnosti Vývojové + Soubory Obecné Více Synchronizovat @@ -881,6 +894,8 @@ Zaregistrovat se u poskytovatele Povolit %1$s přístup k vašemu Nextcloud účtu %2$s? Řadit podle + Seřadit od oblíbených + Při řazení zobrazovat složky a pak až soubory Skrýt Podrobnosti Totožnost serveru se nepodařilo ověřit @@ -990,6 +1005,7 @@ Událost nenalezena, ale ještě pořád je možné aktualizovat synchronizací. Přesměrovává se na web… Kontakt nenalezen, ale ještě pořád je možné aktualizovat synchronizací. Přesměrovává se na web… Pro otevření výsledků vyhledávání je zapotřebí oprávnění – pokud chybí, přesměruje na web… + V této složce Neznámé Odemknout soubor Existuje nepřečtený komentář @@ -1097,6 +1113,8 @@ Nedůvěryhodný certifikát serveru Zjišťování verze serveru… Aplikace ukončena + Přeskočeno + Takto nazvaný soubor už existuje. Dokončeno Stejný soubor nalezen na vzdáleném – nahrání bude přeskočeno Neznámá chyba @@ -1220,6 +1238,12 @@ Bude exportováno %d souborů. Podrobnosti viz upozornění. Budou exportovány %d soubory. Podrobnosti viz upozornění. + + Je možné nahrávat pouze %d soubor naráz. + Je možné nahrávat pouze %d soubory naráz. + Je možné nahrávat pouze %d souborů naráz. + Je možné nahrávat pouze %d soubory naráz. + %1$d složka %1$d složky diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index d6647e3..2a49ec6 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -15,7 +15,7 @@ List visning Gendan kontakter og kalender Ny mappe - Flyt eller kopier + Flyt eller kopiér Åbn med Søg Detaljer @@ -93,12 +93,19 @@ %1$s understøtter ikke flere konti Kunne ikke etablere forbindelse Annuller log på + Angiv venligst en gyldig serveradresse. + Kan ikke hent log på detaljer. Prøv venligst igen. Der opstod et problem i forbindelse med din log på forespørgsel. Prøv venligst igen senere. + Der er ingen browser tilgængelig til at åbne dette link. Gennemfør venligst log på processen i din browser + Autoupload er pauseret for batterisparer er aktiveret. Forblevet i oprindelig folder, da den er skrivebeskyttet + Lav batteri, upload kan tage længere tid Upload kun på ubegrænset wifi /AutoUpload Denne mappe er allerede inkluderet i den overordnede mappes synkronisering, hvilket kan medføre dobbelt uploads + Venter på Wi-Fi for at starte upload + Uploader filer fra %s til %s Konfigurer Opret en ny brugerdefineret mappeopsætning Opsæt en brugerdefineret mappe @@ -207,7 +214,7 @@ Det er ikke muligt at kopiere en mappe til en af dens egne undermapper Filen findes allerede i destinationsmappen Ikke muligt at kopiere. Undersøg venligst om filen eksisterer. - Kopier link + Kopiér link Kopier/flyt til krypteret mappe endnu ikke understøttet. Kunne ikke hente hele billedet Kunne ikke hente delte drev @@ -456,9 +463,9 @@ Succes ved genskabning af fil version! Detaljer Download - Eksporter + Eksportér File omdøbt %1$sunder overførelse - Synkroniser + Synkronisér Ingen fil valgt Filnavnet kan ikke stå tomt. Ugyldige tegn: / \\ < > : \" | ? * @@ -471,11 +478,13 @@ Mappe findes allerede Denne mappe vises bedst i %1$s. Opret + %1$d ud af %2$d · %3$s + Synkroniserer... Ingen mappe her Mappenavnet må ikke være tomt Vælg Vælg destinationsmappe - Kopier + Kopiér Flyt Du har ikke tilladelse %s til at kopiere denne fil @@ -558,7 +567,6 @@ Fjern data Indstillinger, database og server certifikater fra %1$s\'s data vil blive permanent slettede.\n\nHentede filer vil forblive uberørte.\n\n Denne proces kan tage lidt tid. Håndter plads - Du har nået din maksimale uploadgrænse. Upload venligst mindre end 500 filer på samme tid. Medie filen kan ikke streames. Kunne ikke læse mediefil Mediefilen har en ugyldig indkodning. @@ -597,7 +605,7 @@ Ingen kalender eksisterer Ingen app tilgængelig til at håndtere mailadresser Ingen elementer - Ingen App tilgængelig til håndtering af Kort + Ingen App tilgængelig til håndtering af kort kun en konto tilladt Ingen App tilgængelig til håndtering af PDF Der er ingen tilgængelig app til at sende de valgte filer @@ -662,13 +670,6 @@ Ingen apps fundet til at vælge et billede med Fastgør til hjemmeskærm Åbn %1$s - .txt - 389 KB - pladsholder - 12:23:45 - Nyligt redigeret - Dette er en pladsholder - 2012/05/18 12:23 PM stop skift Vælg venligst en server... @@ -690,6 +691,7 @@ Om Detaljer Udvikling + Filer Generel Mere Synkronisér @@ -822,7 +824,7 @@ Del Tillad download og synkronisering Vi kunne ikke opdatere delingen. Tilføj en note og prøv igen. - Del og kopier link + Del og kopiér link Opret Brugerdefinerede rettigheder Slet @@ -882,6 +884,8 @@ Indskrivning hos udbyder Tillad %1$s adgang til din Nextcloud konto %2$s? Sorter efter + Vis favoritter først + Sorter mapper før filer Gem Detaljer Serverens identitet kunne ikke verificeres @@ -992,6 +996,7 @@ Event ikke fundet. Du kan altid synkronisere for at opdatere. Omdirigerer til web… Kontakt ikke fundet. Du kan altid synkronisere for at opdatere. Omdirigerer til web… Rettigheder er krævet for at åbne søgeresultat, ellers vil det omdirigere til web... + I denne mappe Ukendt Lås op filen Der er ulæste kommentarer diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 316d90a..47eec8a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -44,6 +44,7 @@ Zeigt ein Widget aus dem Dashboard an Suche in %s Offline erscheinen + Dieser Inhalt wurde von KI generiert und kann Fehler enthalten. Neue Aufgabe hinzufügen Unten rechts eine neue Aufgabe erstellen Bitte einen Text eingeben @@ -55,6 +56,7 @@ Es ist ein Fehler beim Löschen der Aufgabe aufgetreten Aufgabe gelöscht Aufgabenliste ist leer. + Die Aufgabenliste ist leer. Bitte die Konfiguration der Assistenten-App überprüfen. Die Aufgabenliste kann nicht abgerufen werden. Bitte überprüfen Sie Ihre Internetverbindung. Aufgabe löschen Die Aufgabenausgabe ist noch nicht fertig. @@ -98,10 +100,14 @@ Fehler bei der Verarbeitung Ihrer Anmeldeanforderung. Bitte versuchen Sie es später erneut. Es ist kein Browser verfügbar, um diesen Link zu öffnen. Bitte schließen Sie den Anmeldevorgang in Ihrem Browser ab + Der automatische Upload wurde angehalten, da Battery-Saver eingeschaltet ist. im Original-Verzeichnis belassen, da nur lesbar + Niedriger Akkustand, das Hochladen kann länger dauern Nur über gebührenfreies WLAN hochladen /AutoUpload Dieser Ordner ist bereits in der Synchronisierung des übergeordneten Ordners enthalten, was zu doppelten Uploads führen kann + Warte auf WLAN für den Beginn des Hochladens + Lade Dateien von %s nach %s hoch Einrichten Erstellen Sie ein Setup für den eigenen Ordner Erstellen Sie einen eigenen Ordner @@ -205,6 +211,7 @@ Der Import konnte nicht gestartet werden. Bitte erneut versuchen Keine Datei gefunden Wir können Ihr letztes Backup nicht finden! + Erkennen von Inhaltsänderungen In die Zwischenablage kopiert Es ist ein Fehler beim Kopieren der Datei oder des Ordners aufgetreten. Es ist nicht möglich, einen Ordner in einen seiner Unterordner zu kopieren @@ -410,6 +417,8 @@ Keine Ergebnisse für Ihre Suche gefunden Beginnen Sie Ihre Suche Geben Sie in die Suchleiste oben einen Begriff ein, um Dateien, Kontakte, Kalendertermine und mehr in Ihrem Konto zu finden. + Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es später erneut. + Schlechte Verbindung Ordner LIVE Lade… @@ -474,6 +483,11 @@ Ordner existiert bereits Dieser Ordner lässt sich am besten in %1$s anzeigen. Erstellen + %1$d von %2$d · %3$s + Es ist ein Fehler beim Synchronisieren des Ordners %s aufgetreten. + Unzureichender Speicherplatz, Synchronisierung abgebrochen + %s Ordner erfolgreich synchronisiert + Synchronisiere… Keine Ordner vorhanden Der Ordnername darf nicht leer sein Auswählen @@ -561,7 +575,6 @@ Daten löschen Einstellungen, Datenbank und Serverzertifikate von %1$s\'s Daten werden dauerhaft gelöscht.\n\nHerunter geladene Dateien bleiben unangetastet.\n\nDieser Vorgang kann eine Zeit dauern. Verwalte Speicherplatz - Sie haben die maximale Anzahl an Datei-Uploads erreicht. Bitte laden Sie weniger als 500 Dateien gleichzeitig hoch. Die Mediendatei kann nicht gestreamt werden Die Mediendatei konnte nicht gelesen werden Mediendatei ist nicht korrekt encodiert @@ -610,6 +623,8 @@ Aktion konnte nicht ausgeführt werden Benachrichtigungen anzeigen, um auf die Ergebnisse von Hintergrundoperationen zu reagieren Hintergrundvorgänge + Erkennt lokale Dateiänderungen + Inhaltsmonitor Zeigt den Herunterlade-Fortschritt an Downloads Zeigt den Fortschritt der Dateisynchronisierung und die Ergebnisse an @@ -650,6 +665,7 @@ PIN eingeben Die PIN wird jedes mal beim Start der App abgefragt Bitte geben Sie Ihre PIN ein + Die PIN wird jedes mal beim Start der App oder beim erneuten Öffnen nach 5 Sekunden abgefragt Die PINs stimmen nicht überein Bitte Ihre PIN nochmals eingeben Ihre PIN löschen @@ -665,13 +681,6 @@ Keine App gefunden, mit der ein Bid gesetzt werden könnte An Startbildschirm anheften %1$s öffnen - .txt - 389 KB - Platzhalter - 12:23:45 - Kürzlich bearbeitet - Dies ist ein Platzhalter - 18.5.2012 12:23 Stopp Umschalten Bitte einen Server auswählen… @@ -693,6 +702,7 @@ Über Details Dev + Dateien Allgemein Mehr Synchronisieren @@ -884,6 +894,8 @@ Mit Provider anmelden Zulassen, dass %1$s auf Ihr Nextcloud Konto %2$s zugreifen darf? Sortieren nach + Favoriten zuerst sortieren + Ordner vor Dateien sortieren Ausblenden Details Die Identität des Servers konnte nicht verifiziert werden @@ -959,7 +971,7 @@ Nicht genügend Speicherplatz Status-synchronisieren-Button Dateien - Schaltfläche „Synchronisationswarnung“ + Schaltfläche \"Synchronisationswarnung\" Einstellungs-Button Ordner konfigurieren Die Sofort-Uploads wurden vollständig überarbeitet. Konfiguriere Sie Ihren automatischen Uploader im Hauptmenü.\n\nGenießen Sie den verbesserten Auto-Upload. @@ -994,6 +1006,7 @@ Termin nicht gefunden, Sie können jederzeit die Synchronisierung wiederholen. Weiterleiten zum Web… Kontakt nicht gefunden, Sie können jederzeit synchronisieren für eine Aktualisierung. Weiterleiten zum Web… Berechtigungen für das Öffnen der Suchergebnisse notwendig, ansonsten werden Sie zum Web weitergeleitet + In diesem Ordner Unbekannt Datei entsperren Es gibt ungelesene Kommentare @@ -1101,6 +1114,8 @@ Serverzertifikat ist nicht vertrauenswürdig Serverversion wird abgerufen … App beendet + Überspringen + Eine Datei mit demselben Namen ist bereits vorhanden. Fertiggestellt Gleiche Datei auf dem Remote-Server gefunden, überspringe das Hochladen Unbekannter Fehler @@ -1194,6 +1209,10 @@ %d Datei wird exportiert. Einzelheiten finden Sie in der Benachrichtigung. %d Dateien werden exportiert. Einzelheiten finden Sie in der Benachrichtigung. + + Es können bis zu %d Datei gleichzeitig hochgeladen werden. + Es können bis zu %d Dateien gleichzeitig hochgeladen werden. + %1$d Ordner %1$d Ordner diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 55cba02..b3399e7 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -287,6 +287,7 @@ Σφάλμα κατά την εκκίνηση της κάμερας Σφάλμα σάρωσης εγγράφων Λογαριασμοί + Δημιουργήθηκε Όνομα εργασίας Πρόοδος Κατάσταση @@ -531,12 +532,6 @@ Επιπλέον διακαιώματα απαιτούνται για μεταφόρτωση και λήψη αρχείων. Δεν βρέθηκε εφαρμογή για τον ορισμό φωτογραφίας Άνοιγμα %1$s - .txt - 389 KB - 12:23:45 - Επεξεργάστηκε πρόσφατα - Αυτό είναι ένα σημείο placeholder - 2012/05/18 12:23 PM Παύση Εναλλαγή Η απενεργοποίηση του έλεγχου εξοικονόμησης ενέργειας ίσως έχει ως αποτέλεσμα το ανέβασμα αρχείων ενώ βρίσκεστε σε κατάσταση χαμηλής μπαταρίας @@ -554,6 +549,7 @@ Περί Λεπτομέρειες Dev + Αρχεία Γενικά Περισσότερα Συγχρονισμός @@ -701,6 +697,8 @@ Σύνδεση με πάροχο Να επιτραπεί%1$sη πρόσβαση στον λογαριασμό σας Nextcloud %2$s ? Ταξινόμηση κατά + Ταξινόμηση των αγαπημένων πρώτα + Ταξινόμηση φακέλων πριν από τα αρχεία Απόκρυψη Λεπτομέρειες Δεν ήταν δυνατή η επαλήθευση της ταυτότητας του διακομιστή @@ -816,6 +814,7 @@ Μεταφόρτωση περιεχομένου από άλλες εφαρμογές Φωτογραφία Μεταφόρτωση από την φωτογραφική μηχανή + Βίντεο Όνομα αρχείου Τύπος αρχείου Αρχείο συντόμευσης Google Maps(%s) @@ -913,6 +912,10 @@ Αποστολή email Ο φάκελος αποθήκευσης δεδομένων δεν υπάρχει! Αυτό μπορεί να οφείλεται σε μια επαναφορά αντιγράφων ασφαλείας σε άλλη συσκευή. Επιστροφή στην προεπιλογή. Παρακαλούμε ελέγξτε τις ρυθμίσεις για να προσαρμόσετε τον φάκελο αποθήκευσης δεδομένων. + + %d ώρα + %d ώρες + Αδύνατος συγχρονισμός %1$d αρχείου (διενέξεις: %2$d) Αδύνατος συγχρονισμός %1$d αρχείων (διενέξεις: %2$d) @@ -961,6 +964,10 @@ %1$d αρχείο %1$d αρχεία + + %1$d στοιχείο + %1$d στοιχεία + Εμφάνιση %1$dκρυφού φακέλου Εμφάνιση %1$dκρυφών φακέλων diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 6dd01f3..de8cac3 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -372,11 +372,6 @@ Rifuzi Pliaj permesoj bezonataj por el- kaj alŝuti dosierojn. Neniu aplikaĵo trovita por uzi tiun bildon - .txt - 389 KB - 12:23:45 - Ĉi tio estas lokokupilo - 2012/05/18 12:23 forigita konservita en origina dosierujo movita al aplikaĵa dosierujo diff --git a/app/src/main/res/values-es-rAR/strings.xml b/app/src/main/res/values-es-rAR/strings.xml index c82d6dd..2e7b333 100644 --- a/app/src/main/res/values-es-rAR/strings.xml +++ b/app/src/main/res/values-es-rAR/strings.xml @@ -484,7 +484,6 @@ Borrar datos Las configuraciones , base de datos y certificados del servidor de los datos de %1$s serán borrados permanentemente.\n\nLos archivos descargados se mantendrán sin cambios.\n\nEste proceso puede tomar algo de tiempo. Administrar espacio - Ha alcanzado el límite de carga máxima de archivos. Por favor, cargue menos de 500 archivos a la vez. No se puede transmitir el archivo multimedia No fue posible leer el archivo de medios El archivo de medios tiene una codificacion incorrecta @@ -566,11 +565,6 @@ Se requieren permisos adicionales para cargar y descargar archivos. No se encontró ninguna aplicación para establecer una imagen con Abrir %1$s - 389 KB - 12:23:45 - Editado recientemente - Este es un marcador de posición - 2012/05/18 12:23 PM detener cambiar ¡La desactivación de la verificación de ahorro de energía puede provocar la carga de archivos cuando la batería está baja! @@ -590,6 +584,7 @@ Acerca de Detalles Dev + Archivo General Más Sincronizar diff --git a/app/src/main/res/values-es-rCL/strings.xml b/app/src/main/res/values-es-rCL/strings.xml index 1f099bb..15249d5 100644 --- a/app/src/main/res/values-es-rCL/strings.xml +++ b/app/src/main/res/values-es-rCL/strings.xml @@ -369,10 +369,6 @@ Se requieren permisos adicionales para cargar y descargar archivos. No se encontró una aplicación con la cual establecer la imagen Abrir %1$s - 389 KB - 12:23:45 - Este es un marcador de posición - 2012/05/18 12:23 PM Borrado mantenido en la carpeta original movido a la carpeta de la aplicación @@ -381,6 +377,7 @@ Acerca de Detalles Desarrollo + Archivo General Más Sincronizar diff --git a/app/src/main/res/values-es-rCO/strings.xml b/app/src/main/res/values-es-rCO/strings.xml index b1ce06e..9b75011 100644 --- a/app/src/main/res/values-es-rCO/strings.xml +++ b/app/src/main/res/values-es-rCO/strings.xml @@ -514,10 +514,6 @@ Código de seguridad incorrecto Se requieren permisos adicionales para cargar y descargar archivos. No se encontró una aplicación con la cual establecer la imagen - 389 KB - 12:23:45 - Este es un marcador de posición - 2012/05/18 12:23 PM Borrado mantenido en la carpeta original movido a la carpeta de la aplicación @@ -526,6 +522,7 @@ Acerca de Detalles Desarrollo + Archivo General Más Sincronizar diff --git a/app/src/main/res/values-es-rEC/strings.xml b/app/src/main/res/values-es-rEC/strings.xml index 7c85226..bd405da 100644 --- a/app/src/main/res/values-es-rEC/strings.xml +++ b/app/src/main/res/values-es-rEC/strings.xml @@ -525,12 +525,6 @@ Se requieren permisos adicionales para cargar y descargar archivos. No se encontró una aplicación con la cual establecer la imagen Abrir %1$s - .txt - 389 KB - 12:23:45 - Recientemente editados - Este es un marcador de posición - 2012/05/18 12:23 PM detener alternar ¡Desactivar la comprobación de ahorro de energía podría resultar en la carga de archivos cuando la batería esté baja! @@ -548,6 +542,7 @@ Acerca de Detalles Desarrollo + Archivo General Más Sincronizar @@ -693,6 +688,7 @@ Registrarse con el proveedor ¿Permitir que %1$s acceda a tu cuenta de Nextcloud %2$s? Ordenar por + Ordenar primero los favoritos. Ocultar Detalles La identidad del servidor no pudo ser verificada diff --git a/app/src/main/res/values-es-rMX/strings.xml b/app/src/main/res/values-es-rMX/strings.xml index 710716b..6fa6dcc 100644 --- a/app/src/main/res/values-es-rMX/strings.xml +++ b/app/src/main/res/values-es-rMX/strings.xml @@ -489,7 +489,6 @@ Borrar datos Las configuraciones, base de datos y certificados del servidor de los datos de %1$s serán eliminados permanentemente.\n\nLos archivos descargados se mantendrán sin cambios.\n\nEste proceso puede tomar algo de tiempo. Administrar espacio - Ha alcanzado el límite de carga máxima de archivos. Por favor, cargue menos de 500 archivos a la vez. No se puede transmitir el archivo multimedia No fue posible leer el archivo de medios El archivo de medios tiene una codificación incorrecta @@ -572,11 +571,6 @@ Se requieren permisos adicionales para cargar y descargar archivos. No se encontró una aplicación con la cual establecer la imagen Abrir %1$s - 389 KB - 12:23:45 - Editado recientemente - Este es un marcador de posición - 2012/05/18 12:23 PM detener alternar ¡Desactivar la comprobación de ahorro de energía podría resultar en la carga de archivos cuando la batería esté baja! @@ -596,6 +590,7 @@ Acerca de Detalles Desarrollo + Archivo General Más Sincronizar @@ -763,6 +758,8 @@ Registrarse con el proveedor ¿Permitir que %1$s acceda a su cuenta de Nextcloud %2$s? Ordenar por + Ordenar los favoritos primero + Ordenar carpetas antes que archivos Ocultar Detalles La identidad del servidor no pudo ser verificada diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 89d5304..d15ec90 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -102,6 +102,7 @@ Subir sólo con conexión Wi-Fi sin límite de datos /CargaAutomática Esta carpeta ya está incluida en la sincronización de la carpeta principal, lo que puede provocar subidas duplicadas. + Subiendo archivos desde %s a %s Configurar Crear nueva configuración de una carpeta especifica Configure una carpeta especifica @@ -561,7 +562,6 @@ Limpiar datos Las opciones, certificados y bases de datos de %1$s serán borrados permanentemente.\n\nLos archivos bajados no se tocarán.\n\nEste proceso tardará algún tiempo. Gestionar espacio - Has alcanzado el límite máximo de subida de archivos. Por favor, suba menos de 500 archivos cada vez. El archivo multimedia no puede ser transmitido El archivo de medios no se ha podido leer El archivo de medios tiene una codificación incorrecta @@ -665,13 +665,6 @@ No se ha encontrado una app para establecer con ella la imagen Anclar a la pantalla de inicio Abrir %1$s - .txt - 389 KB - marcador de posición - 12:23:45 - Editado recientemente - Esto es un marcador de posición - 18/05/2012 12:23 PM parar cambiar Por favor, seleccione un servidor… @@ -693,6 +686,7 @@ Acerca de Detalles Dev + Archivos General Más Sincronizar @@ -884,6 +878,8 @@ Registrarse con un proveedor ¿Permitir a %1$s acceder a tu cuenta de Nextcloud %2$s? Ordenar por + Ordenar los favoritos primero + Ordenar carpetas antes que archivos Ocultar Detalles No se ha podido verificar la identidad del servidor diff --git a/app/src/main/res/values-et-rEE/strings.xml b/app/src/main/res/values-et-rEE/strings.xml index c589e29..797d8b1 100644 --- a/app/src/main/res/values-et-rEE/strings.xml +++ b/app/src/main/res/values-et-rEE/strings.xml @@ -44,6 +44,7 @@ Näitab ühte vidinat juhtpaneelilt/töölaualt Otsi siin: %s Sellega paistad olema võrgust väljas + See sisu on tehisaru koostatud ja võib sisaldada vigu. Lisa uus ülesanne Loo uus ülesanne all paremal asuvast valikust Kirjuta midagi vahvat @@ -55,6 +56,7 @@ Ülesande kustutamisel tekkis viga Ülesande kustutamine õnnestus Ülesannete loend on tühi. + Ülesannete loend on tühi. Kontrolli Abilise rakenduse seadistusi. Ülesannete loendi laadimine ei õnnestunud. Palun kontrolli oma nutiseadme internetiühenduse toimivust. Kustuta ülesanne Ülesande väljund pole veel valmis. @@ -98,10 +100,14 @@ Sinu sisselogimispäringu töötlemisel tekkis viga. Palun proovi hiljem uuesti. Selle lingi avamiseks ei leidu ühtegi brauserit. Palun lõpeta sisselogimisprotsess oma veebibrauseris + Kuna akukasutuse optimeerimine on lülitatud sisse, siis automaatne üleslaadimine pole töös. säilitatud lugemisõigustega algkaustas + Aku on üsna tühi, üleslaadimine võib kesta kauem Laadi üles ainult mahupiiranguta WiFi võrgus /AutoUpload Kuna ülalpool asuv kaust kuulub sünkroonimisele, siis see kaust on juba kaasatud ning nii võivad tekkida topelt üleslaadimised + Ootan sünkroonimiseks WiFi ühenduse loomist + Laadin faile üles: %s kuni %s Seadista Loo uus kohandatud kausta seadistus Seadista kohandatud kaust @@ -205,6 +211,7 @@ Ei õnnestunud käivitada importimist. Palun proovi uuesti Faili ei leitud Sinu viimast varukoopiat ei leidu! + Tuvastan sisu muudatusi Kopeeritud lõikepuhvrisse Selle faili või kausta kopeerimisel tekkis tõrge Kausta ei saa kopeerida tema enda alamkausta @@ -410,6 +417,8 @@ Sinu päringul pole tulemusi Alusta otsingut Otsimaks faile, kontakte, kalendrisündmusi ja muud sinu kasutajakontoga seotud teavet, sisesta otsitu ülaltoodud otsinguribale. + Palun kontrolli oma nutiseadme internetiühenduse toimimist või proovi hiljem uuesti + Kehv ühendus kaust OTSE Laadimine… @@ -474,6 +483,11 @@ Kaust on juba olemas Kausta on kõige parem vaadata siin: %1$s. Loo + %1$d / %2$d · %3$s + „%s“ kausta sünkroonimisel tekkis viga + Andmekandjal pole piisavalt ruumi, sünkroonimine on katkestatud + %s kausta sünkroonimine õnnestus + Sünkroonin… Siin ei ole kaustu Kausta nimi ei saa olla tühi. Vali @@ -561,7 +575,6 @@ Kustuta andmed Järgnevaga kustutatakse jäädavalt kõik „%1$s“ andmete seadistused, andmebaasikirjed ja sertifikaadid.\n\nAllalaaditud failid jäävad alles.\n\nKogu toiming võib märgatavalt aega võtta. Halda andmeruumi - Sa üritad laadida üles enam faile kui võimalik on. Palun laadi korraga üles vähem, kui 500 faili. Seda meediafaili ei saa voogedastada Meediafaili lugemine ebaõnnestus Sellel meediafailil on vigane kodeering @@ -610,6 +623,8 @@ Ei õnnestunud käivitada tegevust. Näita teavitusi, mis võimaldavad suhelda taustal tehtud toimingutega Toimingud taustal + Tuvastab kohalike failide muudatused + Sisuvaatleja Näitab allalaadimise edenemist Allalaadimised Näitab failide sünkroniseerimise edenemist ja tulemusi @@ -650,10 +665,11 @@ Sisesta oma täiendav salasõna Täiendavat salasõna küsitakse iga kord, kui sa selle rakenduse käivitad Palun sisesta oma täiendav salasõna + Täiendavat salasõna küsitakse iga kord, kui rakendust avatakse või avatakse uuesti 5 sekundi möödumisel. Täiendavad salasõnad pole samad Palun sisesta täiendav salasõna uuesti Kustuta oma täiendav salasõna - Täiendav salasõna kustutatud + Täiendav salasõna on kustutatud Täiendav salasõna on salvestatud Vale täiendav salasõna Ei õnnestu avada salasõnaga kaitstud pdf-faili. Palun kasuta välist pdf-failide vaatamise rakendust. @@ -665,13 +681,6 @@ Ei leidu rakendust pildi määramiseks Kinnita avalehele Ava %1$s - .txt - 389 KB - kohatäitja - 12:23:45 - Hiljuti muudetud - See on kohahoidja - 2012/05/18 12:23 PM peata lülita sisse/välja Palun vali server… @@ -693,6 +702,7 @@ Info Üksikasjad Arendusversioon + Failid Üldine Rohkem Sünkrooni @@ -724,7 +734,7 @@ Pole kasutusel Kaitse rakendust kasutades Nutiseadmepõhist autentimist - Täiendavat salasõna + Täiendav salasõna Halda kontosid Soovita sõbrale Eemalda krüptimine kohalikus seadmes @@ -884,6 +894,8 @@ Või liitu teenusepakkujaga Kas lubad „%1$s“ teenusel ligipääsu sinu Nextcloudi kasutajakontole „%2$s“? Sorteeri + Järjesta lemmikud esimesena + Järjesta kaustad enne faile Peida Üksikasjad Serveri identiteeti polnud võimalik kontrollida @@ -994,6 +1006,7 @@ Sündmust ei leidu, sa võid andmete uuendamiseks alati sünkroniseerida. Suunan edasi veebi… Kontakti ei leidu, sa võid andmete uuendamiseks alati sünkroniseerida. Suunan edasi veebi… Otsingutulemuste avamiseks on vajalikud vastavad õigused, vastasel juhul suunan päringu ümber veebi… + Selles kaustas Teadmata Eemalda faili lukustus Leidub lugemata kommentaare @@ -1101,6 +1114,8 @@ Serveri sertifikaat pole usaldusväärne Laadin serveris leiduvat versiooni… Rakenduse töö on lõpetatud + Vahelejäetud + Sellise nimega fail on juba olemas. Lõpetatud Sama fail leidub kaugseadmes, jätan üleslaadimise vahele Tundmatu viga @@ -1194,6 +1209,10 @@ Eksportimisele kuulub %d fail. Üksikasjalik info leidub teavituses. Eksportimisele kuulub %d faili. Üksikasjalik info leidub teavituses. + + Ühe korraga saad üles laadida kuni %d faili. + Ühe korraga saad üles laadida kuni %d faili. + %1$d kaust %1$d kausta diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 877893f..4d05aab 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -92,6 +92,7 @@ %1$s(e)k ez ditu hainbat kontu onartzen Ezin izan da konexioa ezarri Utzi saioa hasiera + Mesedez, sartu baliozko zerbitzari baten helbidea Arazo bat egon da zure saioa hasteko eskaera prozesatzean. Mesedez saiatu geroxeago. Mesedez, osatu saioa hasteko prozesua zure nabigatzailean Jatorrizko karpetan mantendu da, soilik irakurtzeko delako @@ -392,6 +393,7 @@ Partekatzen dituzun fitxategiak eta karpetak hemen agertuko dira. Oraindik ez dago ezer partekatuta Ez da emaitzarik aurkitu zure bilaketarentzat + Hasi zure bilaketa karpeta ZUZENEAN Kargatzen… @@ -540,7 +542,6 @@ Garbitu datuak %1$s(r)en ezarpenak, datu-basea eta zerbitzariaren ziurtagiriak betirako ezabatuko dira.\n\n Deskargatutako fitxategiak ez dira ukituko.\n\n Prozesu honek denbora behar du. Kudeatu lekua - Fitxategiak igotzeko gehienezko mugara iritsi zara. Mesedez, ez igo 500 fitxategi baino gutxiago aldi berean. Ezin da multimedia fitxategia transmititu Ezin izan da multimedia fitxategia irakurri Multimedia fitxategiak kodeketa desegokia du @@ -563,6 +564,7 @@ Ezin da karpeta bat azpikarpeta batera mugitu Fitxategi hau existitzen da jadanik helburuko karpetan Ezin izan da fitxategia mugitu. Mesedez egiaztatu fitxategia existitzen dela. + Isilarazi jakinarazpen guztiak Errore bat gertatu da zerbitzariari itxarotean. Ezin izan da eragiketa burutu. Errore bat gertatu da zerbitzariarekin konektatzean. Errore bat gertatu da zerbitzariari itxarotean. Ezin izan da eragiketa burutu. @@ -637,12 +639,6 @@ Ez da aplikaziorik aurkitu irudia ezartzeko Finkatu pantaila nagusian Ireki %1$s - .txt - 389 KB - 12:23:45 - Berriki editatua - Hau leku-marka bat da - 2012/05/18 12:23 PM gelditu txandakatu Hautatu zerbitzari bat... @@ -664,6 +660,7 @@ Honi buruz Xehetasunak Garapena + Fitxategiak Orokorra Gehiago Sinkronizatu @@ -848,6 +845,8 @@ Erregistratu hornitzaile batekin %1$s zure %2$s Nextcloud kontuan sartzea baimendu nahi duzu? Ordenatu honen arabera + Ordenatu gogokoak lehenengo + Ordenatu karpetak fitxategien aurretik Ezkutatu Xehetasunak Ezin izan da zerbitzariaren identitatea egiaztatu @@ -1006,6 +1005,7 @@ Ezin izan da fitxategia kopiatu biltegiratze lokalera Karpeta blokeatzeak huts egin du Igoera bertan behera utzi du erabiltzaileak + Aplikazioaren baimenak %1$d/%2$d-%3$s Zifratzea >= Android 5.0 bertsioarekin soilik da posible Hautatutako fitxategiak ezin dira %1$sra kopiatu ez dagoelako toki nahikorik. Kopiatu ordez bertara mugitu nahi dituzu? diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index f2fd12b..65afda3 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -552,12 +552,6 @@ نیاز به مجوزهای اضافی برای بارگذاری و دریافت پرونده‌ها می باشد. هیچ برنامه ای برای تنظیم عکس یافت نشد باز کنید %1$s - .txt - ۳۸۹ کیلوبایت - 12:23:45 - اخیراً ویرایش شده - این یک حفره است. - 2012/05/18 12:23 بعد از ظهر توقف تغییر وضعیت غیرفعال کردن چک مارک صرفه جویی در مصرف برق ممکن است باعث شود بارگذاری پرونده ها در حالت باتری کم باشد! @@ -577,6 +571,7 @@ درباره جزئیات توسعه + پرونده‌ها عمومی بیش‌تر همگام‌سازی @@ -738,6 +733,8 @@ ثبت‌نام با ارائه دهنده اجازه به %1$s برای دسترسی به حساب نکست‌کلود %2$s؟ مرتب سازی بر اساس + ابتدا موارد دلخواه را مرتب کنید + Sort folders before files پنهان کردن جزییات هویت کارساز نتوانست تأیید شود diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 6c1f6a5..4e90aac 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -578,12 +578,6 @@ Tiedostojen lähetys ja lataaminen vaatii lisäoikeuksia. Kuvan liittämiseksi ei löytynyt sovellusta Avaa %1$s - .txt - 389 kt - 12:23:45 - Äskettäin muokattu - Tämä on paikkamerkki - 18.05.2012 12:23 pysäytä vaihda Valitse palvelin... @@ -604,6 +598,7 @@ Tietoja Tiedot Kehittäjä + Tiedostot Yleiset Enemmän Synkronoi @@ -765,6 +760,8 @@ GNU yleinen lisenssi, versio 2 Kirjaudu sisään palvelutarjoajan kautta Salli käyttäjälle %1$s pääsy Nextcloud -tilillesi %2$s? Lajittelujärjestys + Järjestä suosikit ensiksi + Järjestä kansiot ennen tiedostoja Piilota Tiedot Palvelimen identiteettiä ei voitu vahvistaa diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 108578d..0b99e08 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -44,6 +44,7 @@ Affiche un widget du tableau de bord Recherche dans %s Apparaître hors-ligne + Ce contenu a été généré par IA et peut faire des erreurs. Ajouter une nouvelle tâche Créer une nouvelle tâche en bas à droite Tapez du texte @@ -55,6 +56,7 @@ Une erreur est survenue lors de la suppression de la tâche Tâche supprimée avec succès La liste des tâches est vide. + La liste des tâches est vide. Vérifiez la configuration de l’application de l’assistant. Impossible de récupérer la liste des tâches, veuillez vérifier votre connexion Internet. Supprimer la tâche Le résultat de la tâche n\'est pas encore prêt. @@ -93,12 +95,19 @@ %1$s ne prend pas en charge les comptes multiples Impossible d\'établir la connexion Annuler la connexion + Veuillez saisir une adresse valide de serveur. + Impossible de récupérer les informations de connexion. Veuillez réessayer. Un problème est survenu lors du traitement de votre demande de connexion. Veuillez réessayer plus tard. + Aucun navigateur n\'est disponible pour ouvrir ce lien. Veuillez finaliser la connexion dans votre navigateur + Le téléversement automatique est suspendu, car l\'économiseur de batterie est activé. conservé dans le dossier original (puisqu\'il est en lecture seule) + Batterie faible, le téléversement peut prendre plus de temps Téléverser par Wi-Fi uniquement /TéléversementAuto Ce dossier fait partie de la synchronisation du dossier parent, ce qui peut créer des doublons. + En attente du Wi-Fi pour commencer le téléversement + Téléversement des fichiers de %s vers %s Configurer Créer une nouvelle configuration de dossier personnalisé Définir un dossier personnalisé @@ -202,6 +211,7 @@ L\'importation n\'a pas pu démarrer. Veuillez réessayer. Aucun fichier trouvé Impossible de trouver la dernière sauvegarde ! + Détection des modifications de contenu Copié dans le presse-papier Une erreur est survenue lors de la copie de ce fichier ou dossier Il n\'est pas possible de copier un dossier vers un de ses descendants @@ -407,6 +417,8 @@ Aucun résultat trouvé pour votre requête Démarrer votre recherche Tapez dans la barre de recherche ci-dessus pour trouver des fichiers, des contacts, des événements de calendrier et plus encore dans votre compte. + Vérifiez votre connexion internet ou ré-essayez plus tard + Connexion limitée dossier EN DIRECT Chargement… @@ -471,6 +483,11 @@ Le dossier existe déjà Ce dossier est mieux visualisé dans %1$s. Créer + %1$d de %2$d · %3$s + Une erreur s\'est produite lors de la synchronisation du dossier %s + Espace disque insuffisant, synchronisation annulée + Dossier %s synchronisé avec succès + Synchronisation en cours… Aucun dossier Le nom du dossier ne peut être vide Choisir @@ -558,7 +575,6 @@ Effacer les données Les paramètres, la base de données et les certificats du serveur provenant de %1$s seront définitivement effacés. \n\nLes fichiers téléchargés ne seront pas impactés.\n\nCette opération peut prendre du temps. Gestion de l\'espace - Vous avez atteint la limite maximum de téléversement. Veuillez téléverser moins de 500 fichiers à la fois. Le fichier ne peut être streamer Impossible de lire le fichier média Le fichier média n\'est pas correctement encodé @@ -607,6 +623,8 @@ Échec lors de l\'exécution de l\'action. Afficher des notifications pour interagir avec le résultat des opérations en arrière-plan Opérations en arrière-plan + Détecte les modifications apportées aux fichiers locaux + Observateur de contenu Afficher la progression de téléchargement Téléchargements Afficher les résultats et la progression de synchronisation du fichier @@ -647,6 +665,7 @@ Saisissez votre code de sécurité Le code de sécurité sera demandé à chaque ouverture de l\'application Veuillez saisir votre code de sécurité + Le code de sécurité sera demandé chaque fois que l’application est ouverte ou rouverte après 5 secondes. Les codes de sécurité ne sont pas identiques Veuillez saisir de nouveau votre code de sécurité Supprimer votre code de sécurité @@ -662,13 +681,6 @@ Aucune application trouvée pour utiliser cette image Épingler sur l\'écran d\'accueil Ouvrir %1$s - .txt - 389 Ko - exemple - 12:23:45 - Modifié récemment - Ceci est un espace réservé - 18/05/2012 12:23 PM arrêter inverser Veuillez sélectionner un serveur… @@ -690,6 +702,7 @@ À propos Préférences Dév + Fichiers Général Plus Synchroniser @@ -815,7 +828,7 @@ Impossible de définir la limite de téléchargement. Veuillez vérifier les autorisations. Définir le message Définir la note - Statut de connexion + Statuts de connexion Utiliser l\'image comme Pendant la configuration du chiffrement de bout en bout, vous recevrez une phrase secrète aléatoire de 12 mots dont vous aurez besoin pour ouvrir vos fichiers sur d\'autres appareils. Cette phrase secrète ne sera stockée que sur cet appareil, et pourra être affichée à nouveau sur cet écran. Veuillez la noter en lieu sûr ! Partager @@ -881,6 +894,8 @@ Se connecter avec un fournisseur Autoriser %1$s à accéder à votre compte Nextcloud %2$s ? Trier par + Trier les favoris en premier + Trier les dossiers avant les fichiers Masquer Détails L\'identité du serveur n\'a pas pu être vérifiée @@ -991,6 +1006,7 @@ Événement introuvable, vous pouvez toujours synchroniser pour mettre à jour. Redirection au web… Contact introuvable, vous pouvez toujours synchroniser pour mettre à jour. Redirection au web... Des permissions sont requises pour ouvrir le résultat de recherche, autrement ceci redirigera au web… + Dans ce dossier Inconnu Déverrouiller le fichier Il y a des commentaire non lus @@ -1098,6 +1114,8 @@ Certificat du serveur non approuvé Récupération de la version serveur … L\'application s\'est arrêtée + Ignoré + Un fichier portant le même nom existe déjà. Terminé Le même fichier a été trouvé sur le serveur distant, le téléversement est ignoré Erreur inconnue @@ -1206,6 +1224,11 @@ %d fichiers vont être exportés. Voir la notification pour plus de détails. %d fichiers vont être exportés. Voir la notification pour plus de détails. + + Vous pouvez téléverser uniquement 1%d fichier à la fois. + Vous pouvez téléverser jusqu\'à %d fichiers à la fois. + Vous pouvez téléverser jusqu\'à %d fichiers à la fois. + %1$d dossier %1$d dossiers diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index c7642e5..6a1a18a 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -44,6 +44,7 @@ Taispeáin giuirléid amháin ón deais Cuardaigh i %s Le feiceáil as líne + Gineadh an t-ábhar seo le hintleacht shaorga agus is féidir botúin a dhéanamh ann. Cuir tasc nua leis Cruthaigh tasc nua ón mbun ar dheis Clóscríobh roinnt téacs @@ -55,6 +56,7 @@ Tharla earráid agus an tasc á scriosadh D\'éirigh leis an tasc a scriosadh Tá liosta tascanna folamh. + Tá an liosta tascanna folamh. Seiceáil cumraíocht aip an chúntóra. Ní féidir liosta tascanna a fháil, seiceáil do nasc idirlín le do thoil. Scrios Tasc Níl an t-aschur tasc réidh fós. @@ -93,12 +95,19 @@ Ní thacaíonn %1$s le cuntais iolracha Níorbh fhéidir ceangal a bhunú Cealaigh Logáil Isteach + Cuir isteach seoladh freastalaí bailí le do thoil. + Ní féidir sonraí logála isteach a fháil. Déan iarracht arís. Bhí fadhb ann d\'iarratas logáil isteach a phróiseáil. Bain triail eile as ar ball le do thoil. + Níl aon bhrabhsálaí ar fáil chun an nasc seo a oscailt. Críochnaigh an próiseas logáil isteach i do bhrabhsálaí le do thoil + Tá an uaslódáil uathoibríoch ar sos mar go bhfuil Coigilteoir Ceallraí ar siúl. coinnithe sa bhunfhillteán, mar atá sé inléite amháin + Ceallraí íseal, d\'fhéadfadh sé go dtógfadh an uaslódáil níos faide Uaslódáil ar Wi-Fi neamh-mhéadraithe amháin /Uaslódáil Uathoibríoch Tá an fillteán seo san áireamh cheana féin i gcomhshioncronú an fhillteáin tuismitheora, rud a d’fhéadfadh uaslódálacha dúblacha a chruthú + Ag fanacht go dtosóidh Wi-Fi ag uaslódáil + Ag uaslódáil comhad ó %s go %s Cumraigh Cruthaigh socrú fillteán saincheaptha nua Socraigh fillteán saincheaptha @@ -202,6 +211,7 @@ Theip ar an iompórtáil. Bain triail eile as Níor aimsíodh aon chomhad Níorbh fhéidir do chúltaca deiridh a aimsiú! + Athruithe ábhair á mbrath Cóipeáladh chuig an ngearrthaisce Tharla earráid agus iarracht á déanamh an comhad nó an fillteán seo a chóipeáil Ní féidir fillteán a chóipeáil isteach i gceann dá bhunfhillteáin féin @@ -407,6 +417,8 @@ Níor aimsíodh aon torthaí do do cheist Tosaigh do chuardach Clóscríobh sa bharra cuardaigh thuas chun comhaid, teagmhálacha, imeachtaí féilire agus tuilleadh a aimsiú ar fud do chuntais. + Seiceáil do nasc idirlín nó déan iarracht arís ar ball + Droch-nasc fillteán BEO Á lódáil… @@ -471,6 +483,11 @@ Tá fillteán ann cheana féin Is fearr féachaint ar an bhfillteán seo i %1$s. Cruthaigh + %1$d de %2$d · %3$s + Tharla earráid le linn sioncrónú an fhillteáin %s + Gan dóthain spáis diosca, sioncrónú curtha ar ceal + Sioncrónaíodh an fillteán %s go rathúil + Ag sioncrónú… Níl fillteáin anseo Ní féidir ainm fillteáin a bheith folamh Roghnaigh @@ -558,7 +575,6 @@ Sonraí soiléire Scriosfar socruithe, bunachar sonraí agus teastais an fhreastalaí ó shonraí %1$s go buan. \n\nCoimeádfar comhaid íosluchtaithe gan teagmháil.\n\nTógfaidh an próiseas seo tamall. Bainistigh spás - Tá an uasteorainn uaslódáil comhad sroichte agat. Uaslódáil níos lú ná 500 comhad ag an am céanna. Ní féidir an comhad meán a shruthú Níorbh fhéidir an comhad meán a léamh Tá ionchódú mícheart ar an gcomhad meán @@ -607,6 +623,8 @@ Theip ar an ngníomh a chur i gcrích. Taispeáin fógraí chun idirghníomhú a dhéanamh ar thoradh oibríochtaí cúlra Oibríochtaí cúlra + Braitheann athruithe ar chomhaid áitiúla + Breathnóir ábhair Léiríonn dul chun cinn íoslódáil Íoslódálacha Taispeánann sé dul chun cinn agus torthaí sioncronaithe comhad @@ -647,6 +665,7 @@ Cuir isteach do phaschód Iarrfar an paschód gach uair a thosófar an app Cuir isteach do phaschód le do thoil + Iarrfar an pasfhocal gach uair a osclófar nó a athosclófar an aip tar éis 5 soicind. Níl na paschóid mar an gcéanna Cuir isteach do phaschód arís le do thoil Scrios do phaschód @@ -662,13 +681,6 @@ Níor aimsíodh aon aip chun pictiúr a shocrú leis Pinn chuig an scáileán baile Oscail %1$s - .txt - 389 KB - áitchoinneálaí - 12:23:45 - Curtha in eagar le déanaí - Is sealbhóir áit é seo - 2012/05/18 12:23 PM stad scoránaigh Roghnaigh freastalaí le do thoil… @@ -690,6 +702,7 @@ Faoi Sonraí Dev + Comhaid Ginearálta Tuilleadh Sioncrónaigh @@ -881,6 +894,8 @@ Cláraigh leis an soláthraí Ceadaigh do %1$s rochtain a fháil ar do chuntas Nextcloud %2$s? Sórtáil de réir + Sórtáil na cinn is ansa leat ar dtús + Sórtáil fillteáin roimh chomhaid Folaigh Sonraí Níorbh fhéidir aitheantas an fhreastalaí a fhíorú @@ -991,6 +1006,7 @@ Níor aimsíodh an t-imeacht, is féidir leat sioncronú a dhéanamh i gcónaí chun nuashonrú a dhéanamh. Á atreorú chuig an ngréasán… Níor aimsíodh an teagmhálaí, is féidir leat sioncronú a dhéanamh i gcónaí chun nuashonrú a dhéanamh. Á atreorú chuig an ngréasán… Teastaíonn ceadanna chun toradh cuardaigh a oscailt nó déanfar é a atreorú chuig an ngréasán… + Sa bhfillteán seo Anaithnid Díghlasáil an comhad Tá tuairimí neamhléite ann @@ -1098,6 +1114,8 @@ Teastas freastalaí neamhiontaofa Leagan an fhreastalaí á fháil… Cuireadh deireadh leis an aip + Scipeáilte + Tá comhad leis an ainm céanna ann cheana féin. Críochnaithe Fuarthas an comhad céanna ar chianchlár, gan bacadh le huaslódáil Earráid anaithnid @@ -1236,6 +1254,13 @@ easpórtálfar %d comhad. Féach an fógra le haghaidh sonraí. easpórtálfar %d comhad. Féach an fógra le haghaidh sonraí. + + Ní féidir leat ach %d comhad a uaslódáil ag an am céanna. + Is féidir leat suas le %d comhad a uaslódáil ag an am céanna. + Is féidir leat suas le %d comhad a uaslódáil ag an am céanna. + Is féidir leat suas le %d comhad a uaslódáil ag an am céanna. + Is féidir leat suas le %d comhad a uaslódáil ag an am céanna. + %1$d fillteán %1$d fillteán diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 823d9f1..87198a3 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -433,10 +433,6 @@ Diùlt Tha feum air barrachd cheadan mus gabh faidhlichean a luchdadh suas is a-nuas. Cha deach aplacaid a lorg airson dealbhan a chur leatha - 389 KB - 12:23:45 - Seo glèidheadair-àite - 2012/05/18 12:23f cuir stad air toglaich Ma chuireas tu à comas dearbhadh caomhnadh cumhachd, dh’fhaoidte gun dèid faidhlichean a luchdadh suas nuair a bhios am bataraidh fann! @@ -454,6 +450,7 @@ Mu dhèidhinn Mion-fhiosrachadh Leasachadh + Faidhlichean Coitcheann Barrachd Sioncronaich diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 0919afa..e05087b 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -44,6 +44,7 @@ Amosa un trebello do taboleiro Buscar en %s Aparecer como sen conexión + Este contido foi xerado por IA e pode conter erros. Engadir unha nova tarefa Crear unha nova tarefa a desde a parte inferior dereita Escriba algún texto @@ -55,6 +56,7 @@ Produciuse un erro ao eliminar a tarefa A tarefa foi eliminada satisfactoriamente A lista de tarefas está baleira + A lista de tarefas está baleira. Comprobe a configuración da aplicación do asistente. Non é posíbel recuperar a lista de tarefas. Comprobe a conexión a Internet. Eliminar tarefa A saída da tarefa aínda non está preparada. @@ -94,14 +96,18 @@ Non foi posíbel estabelecer a conexión Cancelar o acceso Introduza un enderezo de servidor válido. - Non foi posíbel obter os datos do acceso. Ténteo de novo. + Non é posíbel obter os datos do acceso. Ténteo de novo. Houbo un problema ao procesar a súa solicitude de acceso. Ténteo de novo máis tarde. Non hai ningún navegador dispoñíbel para abrir esta ligazón. Complete o proceso de acceso no seu navegador + O envío automático está en pausa porque o aforro de batería está activado. mantense no cartafol orixinal, xa que é de só lectura + Batería baixa, o envío pode levar máis tempo Enviar só con wifi sen límite de datos /EnvíoAutomático Este cartafol xa está incluído na sincronización do cartafol principal, iso pode provocar envíos duplicados + Agardando pola wifi para iniciar o envío + Enviar ficheiros de %s a %s Configurar Crear un cartafol personalizado novo Configurar un cartafol personalizado @@ -182,7 +188,7 @@ Axúdenos facendo probas Informe dun incidente no GitHub Configurar - Retirar o cifrado local + Retirar a cifraxe local Confirma que quere eliminar %1$s? Confirma que quere eliminar os elementos seleccionados? Confirma que quere eliminar %1$s e todo o seu contido? @@ -205,6 +211,7 @@ Importación fallou ao iniciar. Ténteo de novo Non se atopou ningún ficheiro Non foi posíbel atopar a súa última copia de seguranza! + Detección de cambios no contido Copiado no portapapeis Produciuse un erro ao tentar copiar este ficheiro ou cartafol. Non é posíbel copiar un cartafol nun dos seus propios subcartafoles @@ -312,21 +319,21 @@ Definir como cifrado Non é posíbel recuperar o certificado de servidor Produciuse un fallo ao verificar a chave pública - Configurar o cifrado + Configurar a cifraxe Descifrando… Pechar Introduza a súa frase de contrasinal para acceder aos seus ficheiros Este cartafol non está baleiro Xerando chave novas… As 12 palabras xuntas forman un contrasinal moi forte, permitíndolle só a Vde. ver e facer uso dos seus ficheiros cifrados. Escríbaas e mantéñaas nun lugar seguro. - Cifrado de extremo a extremo desactivado no servidor. - Tome nota do seu contrasinal de cifrado de 12 palabras + Cifraxe de extremo a extremo desactivado no servidor. + Tome nota do seu contrasinal de cifraxe de 12 palabras Contrasinal… Recuperando chaves… Non é posíbel recuperar a chave privada Non é posíbel recuperar a chave pública Almacenando as chaves - Configurar o cifrado + Configurar a cifraxe Produciuse un erro non agardado ao descargar as chaves Non foi posíbel gardar as súas chaves. Ténteo de novo Produciuse un erro ao descifrar. Contrasinal incorrecto? @@ -346,7 +353,7 @@ Produciuse un erro ao recuperar o ficheiro Produciuse un erro ao recuperar modelos Produciuse un erro ao axustar a mensaxe de estado! - Produciuse un erro ao amosar o diálogo de configuración do cifrado! + Produciuse un erro ao amosar o diálogo de configuración da cifraxe! Produciuse un erro ao iniciar a cámara Produciuse un erro ao iniciar o escaneamento do documento Produciuse un fallo ao enviar os ficheiros multimedia capturados. @@ -410,6 +417,8 @@ Non se atopou ningún resultado para a súa consulta Comezar a súa busca Escribir na barra de busca enriba para atopar ficheiros, contactos, eventos de calendario, e máis na súa conta. + Comprobe a súa conexión a Internet ou ténteo de novo máis tarde. + Conexión deficiente cartafol DIRECTO Cargando… @@ -450,7 +459,7 @@ %s é un nome prohibido. %s. Cambie o nome do ficheiro antes de mover ou copiar Non se atopou o ficheiro - Non se atopou o ficheiro. Non foi posíbel crear unha compartición. + Non se atopou o ficheiro. Non é posíbel crear unha compartición. Non foi posíbel sincronizar o ficheiro. Amosase a última versión dispoñíbel. Cambiar o nome Produciuse un fallo no envío. Non hai conexión a Internet @@ -474,6 +483,11 @@ Xa existe o cartafol Este cartafol vese mellor en %1$s. Crear + %1$d de %2$d · %3$s + Produciuse un erro durante a sincronización do cartafol %s + Non hai espazo abondo no disco, cancelouse a sincronización + O cartafol %s foi sincronizado correctamente + Sincronizando… Aquí non hai cartafoles O nome do cartafol non pode estar baleiro Escoller @@ -561,7 +575,6 @@ Limpar os datos Van ser eliminados definitivamente os axustes , base de datos e certificados do servidor de %1$s.\n\nOs ficheiros descargados conservaranse sen cambios.\n\nEste proceso pode levar bastante tempo. Xestionar o espazo - Acadou o límite máximo de envío de ficheiros. Envíe menos de 500 ficheiros dunha sentada. Non é posíbel transmitir o ficheiro multimedia Non foi posíbel ler o ficheiro multimedia O ficheiro multimedia ten unha codificación incorrecta @@ -610,6 +623,8 @@ Produciuse un fallo ao executar a acción. Amosar notificacións para interactuar co resultado das operacións en segundo plano Operacións en segundo plano + Detecta os cambios nos ficheiros locais + Observador do contido Amosa o progreso da descarga Descargas Amosa o progreso e os resultados da sincronización de ficheiros @@ -631,7 +646,7 @@ Operación pendente Operación de retirada pendente Sen conexión a Internet - Mesmo sen conexión a Internet, pode organizar os seus cartafoles, crear ficheiros. Unha vez que volva estar conectado, as túas accións pendentes sincronizaranse automaticamente. + Mesmo sen conexión a Internet, pode organizar os seus cartafoles, crear ficheiros. Unha vez que volva estar conectado, as súas accións pendentes sincronizaranse automaticamente. Está sen conexión, mais o traballo continúa O ficheiro aínda non existe. Envíe primeiro o ficheiro. Non foi posíbel crear %s. Existe un ficheiro co mesmo nome no servidor. @@ -650,6 +665,7 @@ Introduza o seu código de acceso Solicitaráselle o código de acceso cada vez que inicie a aplicación Introduza o seu código de acceso + Solicitaráselle o código de acceso cada vez que se abra a aplicación ou se volva abrir após 5 segundos. Os códigos de acceso non son iguais Introduza de novo o seu código de acceso Elimine o seu código de acceso @@ -665,13 +681,6 @@ Non se atopou unha aplicación coa que definir unha imaxe Fixar na pantalla de Inicio Abrir %1$s - .txt - 389 KB - marcador de substitución - 12:23:45 - Editado recentemente - Isto é un marcador de substitución - 18/05/2012 12:23 p.m. parar alternar Seleccione un servidor… @@ -693,6 +702,7 @@ Sobre Detalles Desenvolvemento + Ficheiros Xeral Máis Sincronizar @@ -701,8 +711,8 @@ Localización do almacenamento de datos Xestionar a localización do almacenamento de datos Produciuse un erro non agardado ao configurar DAVx⁵ (anteriormente coñecido como DAVdroid) - O cifrado de extremo a extremo está configurado. - Mnemotécnico do cifrado E2E + A cifraxe de extremo a extremo está configurado. + Mnemotécnico da cifraxe E2E Para amosar o mnemotécnico, active as credenciais do dispositivo Amosar as notificacións de escaneo de medios @@ -717,7 +727,7 @@ Arquivar en subcartafoles baseados na data Usar subcartafoles Opcións de subcartafol - Engadir cifrado de extremo a extremo a este cliente + Engadir cifraxe de extremo a extremo a este cliente Licenza Código de acceso da aplicación Credenciais do dispositivo activadas @@ -728,10 +738,10 @@ Código de acceso Xestionar contas Recomendar a un amigo - Retirar o cifrado localmente - Configurar o cifrado de extremo a extremo + Retirar a cifraxe localmente + Configurar a cifraxe de extremo a extremo Amosar o conmutador de aplicacións - Suxestións da aplicación Nextcloud no título de navegación + Suxestión de aplicacións de Nextcloud na cabeceira de navegación Amosar ficheiros agochados Obter o código fonte Xestionar os cartafoles para a envío automático @@ -766,8 +776,8 @@ Volver cargar (remoto) Produciuse un fallo ao atopar o ficheiro! - Pode retirar o cifrado de extremo a extremo localmente neste cliente - Pode retirar o cifrado de extremo a extremo localmente neste cliente. Os ficheiros cifrados permanecerán no servidor, mais xa non se sincronizarán con este computador. + Pode retirar a cifraxe de extremo a extremo localmente neste cliente + Pode retirar a cifraxe de extremo a extremo localmente neste cliente. Os ficheiros cifrados permanecerán no servidor, mais xa non se sincronizarán con este computador. Produciuse un fallo na eliminación Retirar a conta local Retirar a conta do dispositivo e eliminar todos os ficheiros locais @@ -778,7 +788,7 @@ Non foi posíbel cambiarlle o nome a copia local, ténteo cun un nome diferente Non foi posíbel cambiarlle o nome, o nome xa está ocupado Solicitar a eliminación da conta - Solicitat a eliminación + Solicitar a eliminación Solicitar a eliminación definitiva da conta polo provedor de servizos A directiva ou os permisos impiden volver compartir Restaurar ficheiro @@ -821,7 +831,7 @@ Definir a nota Estado en liña Usar a imaxe como - Durante a configuración do cifrado de extremo a extremo, recibirá un mnemotécnico ao chou de 12 palabras, que necesitará para abrir os seus ficheiros noutros dispositivos. Isto só se almacenará neste dispositivo e pódese amosar de novo nesta pantalla. Anóteo nun lugar seguro! + Durante a configuración da cifraxe de extremo a extremo, recibirá un mnemotécnico ao chou de 12 palabras, que necesitará para abrir os seus ficheiros noutros dispositivos. Isto só se almacenará neste dispositivo e pódese amosar de novo nesta pantalla. Anóteo nun lugar seguro! Compartir Permitir a descarga e a sincronización Non foi posíbel actualizar a compartición. Engada unha nota e ténteo de novo. @@ -885,6 +895,8 @@ Rexistrarse cun provedor Permitirlle a %1$s acceder a súa conta %2$s en Nextcloud? Ordenar por + Ordenar primeiro os favoritos + Ordenar os cartafoles antes que os ficheiros Agochar Detalles Non foi posíbel verificar a identidade do sitio @@ -904,7 +916,7 @@ Ata: - Non hai información sobre este erro Non foi posíbel gardar o certificado - Non é posíbel amosar o certificado. + Non foi posíbel amosar o certificado. Aínda así, quere fiar neste certificado igualmente? - O certificado do servidor caducou - O certificado do servidor non é de confianza @@ -923,7 +935,7 @@ Acceso completo Medios de só lectura Imaxes - A plataforma de produtividade en aloxamento autónomo que mantén controlada.\n\nCaracterísticas:\n* Interface doada e moderna, totalmente tematizada ao aliñarse co tema do seu servidor\n* Enviar os seus ficheiros ao seu Nextcloud\n* Compartir os seus ficheiros con outras persoas\n* Conservar os seus ficheiros e cartafoles favoritos sincronizados\n* Buscar en todos os cartafoles do servidor\n* Enviar automaticamente fotos e vídeos feitos no seu dispositivo\n* Estar ao día coas notificacións\n* Admite múltiples contas\n* Acceso seguro aos seus datos con pegada dactilar ou PIN\n* Integración con DAVx⁵ (anteriormente coñecido como DAVdroid) para facilitar a configuración da sincronización de calendarios e contactos\n\nAgradecémoslle que nos informe de calquera incidente en https://github.com/nextcloud/android/issues, pode conversar sobre esta aplicación en https://help.nextcloud.com/c/clients/android\n\nE novo en Nextcloud? Nextcloud é un servidor privado para sincronizar e compartir ficheiros. É completamente libre e pode instalalo Vde. ou contratar a unha empresa ou cooperativa para que o faga por Vde. É o camiño para que sexa Vde. quen teña o control sobre as súas fotos, calendario, caderno de contactos, documentos e todo o demais.\n\nCoñeza Nextcloud en https://nextcloud.com + A plataforma de produtividade en aloxamento autónomo que mantén controlada.\n\nCaracterísticas:\n* Interface doada e moderna, totalmente tematizada ao aliñarse co tema do seu servidor\n* Enviar os seus ficheiros ao seu Nextcloud\n* Compartir os seus ficheiros con outras persoas\n* Conservar os seus ficheiros e cartafoles favoritos sincronizados\n* Buscar en todos os cartafoles do servidor\n* Enviar automaticamente fotos e vídeos feitos no seu dispositivo\n* Estar ao día coas notificacións\n* Admite múltiples contas\n* Acceso seguro aos seus datos con pegada dactilar ou PIN\n* Integración con DAVx⁵ (anteriormente coñecido como DAVdroid) para facilitar a configuración da sincronización de calendarios e contactos\n\nAgradecémoslle que nos informe de calquera problema en https://github.com/nextcloud/android/issues, pode conversar sobre esta aplicación en https://help.nextcloud.com/c/clients/android\n\nE novo en Nextcloud? Nextcloud é un servidor privado para sincronizar e compartir ficheiros. É completamente libre e pode instalalo Vde. ou contratar a unha empresa ou cooperativa para que o faga por Vde. É o camiño para que sexa Vde. quen teña o control sobre as súas fotos, calendario, caderno de contactos, documentos e todo o demais.\n\nCoñeza Nextcloud en https://nextcloud.com A plataforma de produtividade en aloxamento autónomo que mantén controlada.\nEsta é a versión oficial de desenvolvemento, que presenta unha mostra diaria de calquera nova funcionalidade aínda non probada, o que pode provocar inestabilidade e perda de datos. Esta aplicación está destinada a usuarios que queren probar e informar de fallos (en caso de producirse). Non a use para o día a día!\n\nTanto a versión oficial de desenvolvemento como a normal están dispoñíbeis no F-Droid e poden instalarse xuntas. A plataforma de produtividade en aloxamento autónomo que mantén controlada A plataforma de produtividade en aloxamento autónomo que mantén controlada (versión de vista previa de desenvolvemento) @@ -951,7 +963,7 @@ Produciuse un fallo na sincronización, acceda de novo. Os contidos do ficheiro xa están sincronizados Non foi posíbel completar a sincronización do cartafol %1$s - A partir da versión 1.3.16, os ficheiros enviados desde este dispositivo cópianse no cartafol local %1$s para evitar a perda de datos cando se sincroniza un único ficheiro con varias contas.\n\nPor mor deste cambio, todos os ficheiros enviados con versións anteriores desta aplicación foron copiados no cartafol %2$s. Porén, un erro impediu que se completara esta operación durante a sincronización da conta. Pode deixar o(s) ficheiro(s) tal e como está(n) e eliminar a ligazón cara a %3$s ou mover o(s) ficheiro(s) para o cartafol %1$s e conservar a ligazón cara a %4$s.\n\nEmbaixo amósanse os ficheiros locais e os ficheiros remotos en %5$s aos que foron enlazados. + A partir da versión 1.3.16, os ficheiros enviados desde este dispositivo cópianse no cartafol local %1$s para evitar a perda de datos cando se sincroniza un único ficheiro con varias contas.\n\nPor mor deste cambio, todos os ficheiros enviados con versións anteriores desta aplicación foron copiados no cartafol %2$s. Porén, un erro impediu que se completara esta operación durante a sincronización da conta. Pode deixar o(s) ficheiro(s) tal e como está(n) e eliminar a ligazón cara a %3$s ou mover o(s) ficheiro(s) para o cartafol %1$s e conservar a ligazón cara a %4$s.\n\nEmbaixo amósanse os ficheiros locais e os ficheiros remotos en %5$s aos que foron ligados. Esquecéronse algúns ficheiros locais Recuperando a versión mais recente do ficheiro. Escoller que sincronizar @@ -995,6 +1007,7 @@ Non se atopou o evento, sempre pode sincronizar para actualizar. Redirixindo á web… Non se atopou o contacto, sempre pode sincronizar para actualizar. Redirixindo á web… Requírense permisos para abrir o resultado da busca, se non, vai ser redirixido á web… + Neste cartafol Descoñecido Desbloquear ficheiro Existen comentarios sen ler @@ -1048,7 +1061,7 @@ Non é posíbel enviar os ficheiros cargar sen acceso ao almacenamento local. Toque para conceder permiso. Envío detido — É necesario permiso de almacenamento %1$d / %2$d - %3$s - O cifrado só é posíbel con >= Android 5.0 + A cifraxe só é posíbel con >= Android 5.0 Non hai espazo abondo para copiar os ficheiros seleccionados no cartafol %1$s. En troques, gustaríalle movelos? Superouse a cota de almacenamento Escanear o documento desde a cámara @@ -1102,6 +1115,8 @@ O certificado do servidor non é fiábel Recuperando a versión do servidor… Aplicación finalizada + Omitido + Xa existe un ficheiro co mesmo nome Completado Atopouse o mesmo ficheiro en remoto, omitindo o envío Produciuse un erro descoñecido @@ -1195,6 +1210,10 @@ Exportarase %d ficheiro. Consulte a notificación para obter máis información. Exportaranse %d ficheiros. Consulte a notificación para obter máis información. + + Pode enviar %d ficheiro cada vez. + Pode enviar ata %d ficheiros á vez. + %1$d cartafol %1$d cartafoles diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index e832e75..668f5f3 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -490,12 +490,6 @@ Spriječi Potrebna su dodatna dopuštenja za otpremanje i preuzimanje datoteka. Nije pronađena nijedna aplikacija za postavljanje slike s - .txt - 389 KB - 12:23:45 - Nedavno uređeno - Ovo je prazan prostor - 18. 5. 2012. 12:23 zaustavi uključi/isključi Onemogućivanje provjere uštede energije može rezultirati otpremanjem datoteka pri niskoj razini napunjenosti baterije! @@ -513,6 +507,7 @@ Informacije Pojedinosti Dev + Datoteke Općenito Više Sinkronizacija diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 8817eb6..5863a59 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -43,6 +43,7 @@ Proxy portja Egy modult jelenít meg a irányítópultról Keresés itt: %s + Megjelenés nem kapcsolódottként Új feladat hozzáadása Új feladat létrehozása a jobb lentiből Gépeljen be szöveget @@ -92,12 +93,17 @@ A %1$s nem támogat több fiókot A kapcsolat létrehozása sikertelen Bejelentkezés megszakítása + Adjon meg egy érvényes kiszolgálócímet. + A bejelentkezés részletei nem kérhetőek le. Próbálja újra. Probléma volt a bejelentkezési kérés feldolgozása során. Próbálja újra később. + Nem érhető el böngésző a hivatkozás megnyitásához. Fejezze be a bejelentkezési folyamatot a böngészőben + Az automatikus feltöltés az akkumulátorkímélő mód miatt szünetel. megtartva az eredeti mappában, mivel csak olvasható Feltöltés csak forgalomkorlát nélküli Wi-Fin /AutoUpload Ez a mappa már szerepel a szülőmappája szinkronizálásában, ami ismételt feltöltéseket okozhat + Fájlok feltöltése innen: %s, ide: %s Beállítás Új egyéni mappabeállítás létrehozása Egyéni mappa beállítása @@ -341,6 +347,7 @@ Jelenti a hibát a követőbe? (GitHub-fiók szükséges) Hiba a fájl lekérésekor Hiba történt a sablonok lékérésekor + Hiba az állapotüzenet beállítása során! Hiba a titkosítás beállítóablakának megjelenítése során! Hiba a kamera indításakor Hiba a dokumentumbeolvasás elindítása során @@ -449,6 +456,7 @@ A fájl nem szinkronizálható. A legfrissebb elérhető verzió megjelenítése. Átnevezés A feltöltés sikertelen. Nincs internetkapcsolat. + A(z) %s már létezik, nincs ütközés észlelve Hiba a fájl verziójának visszaállításakor! Fájl verzió sikeresen visszaállítva. Részletek @@ -555,7 +563,6 @@ Adatok törlése A beállítások, az adatbázisok és a kiszolgáló tanúsítványok véglegesen törlésre kerülnek %1$s adataiból. \n\nA letöltött fájlok érintetlenek maradnak.\n\nEz eltarthat egy darabig. Tárhelykezelés - Elérte a fájlfeltöltési korlátot. 500-nál kevesebb fájlt töltsön fel egyszerre. A médiafájl nem közvetíthető A médiafájl nem olvasható A médiafájl kódolása hibás @@ -578,6 +585,7 @@ Nem helyezhető át egy mappa a saját almappájába A fájl már létezik a célmappában A fájl nem helyezhető át. Ellenőrizze, hogy létezik-e. + Összes értesítés némítása Hiba történt a kiszolgálóra várakozás közben. A művelet nem teljesíthető. Hiba történt a kiszolgálóhoz kapcsolódás során Hiba történt a kiszolgálóra várakozás közben. A művelet nem teljesíthető. @@ -611,6 +619,8 @@ Általános értesítések Zenelejátszó folyamatai Médialejátszó + Az offline fájlműveletek folyamatának megjelenítése + Offline műveletek A kiszolgáló által küldött üzenetek megjelenítése: Említések a megjegyzésekben, új távoli megosztások érkezése, adminisztrátori bejelentések, stb. Leküldéses értesítések Megjeleníti a feltöltési folyamatokat @@ -656,12 +666,6 @@ Nincs alkalmazás a képbeállításhoz Rögzítés a kezdőképernyőre A(z) %1$s megnyitása - .txt - 389 kB - 12:23:45 - Nemrég szerkesztve - Ez egy helykitöltő - 2012.05.18. 12:23 leállítás átváltás Válasszon egy kiszolgálót… @@ -683,6 +687,7 @@ Névjegy Részletek Dev + Fájlok Általános Több Szinkronizálás @@ -806,6 +811,7 @@ Az eszköz valószínűleg nem kapcsolódik az internethez Beállítás mint A letöltési korlát nem állítható be. Ellenőrizze az elérhető funkciókat. + Üzenet beállítása Jegyzet beállítása Elérhető állapot Kép használata mint @@ -873,6 +879,8 @@ Regisztráció egy szolgáltatóval Engedélyezi, hogy a(z) %1$s hozzáférjen a(z) %2$s Nextcloud fiókjához? Rendezés elve + Kedvencek előre rendezése + Mappák fájlok elé rendezése Elrejtés Részletek A kiszolgáló személyazonosságát nem sikerült azonosítani @@ -1001,6 +1009,7 @@ A Nextcloud itt érhető el: https://nextcloud.com Az esemény nem található, bármikor szinkronizálhat a frissítéshez. Átirányítás a webre… A névjegy nem található, bármikor szinkronizálhat a frissítéshez. Átirányítás a webre… Engedély szükséges a keresési találat megnyitásához, különben a webre lesz átirányítva… + Ebben a mappában Ismeretlen Fájl feloldása Olvasatlan hozzászólások vannak diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 81aa8c6..ab6183a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -43,6 +43,7 @@ Proxy Port Menampilkan satu gawit dari dasbor Cari dalam %s + Tampak offline Tambahkan tugas baru Buat tugas baru dari kanan bawah Tulis beberapa teks @@ -92,12 +93,19 @@ %1$s tidak mendukung banyak akun Tidak dapat memulai koneksi Batalkan Masuk + Silakan masukkan alamat server yang valid. + Tidak dapat mengambil detail masuk. Silakan coba lagi. Ada masalah saat memproses permintaan masuk Anda. Coba lagi nanti. + Tidak ada peramban yang tersedia untuk membuka tautan ini. Tolong selesaikan proses login Anda di browser + Unggahan otomatis ditunda karena mode Penghemat Baterai aktif. disimpan di folder original karena readonly + Baterai menipis, proses unggah mungkin memakan waktu lebih lama Hanya unggah di Wi-Fi tak terhitung /AutoUpload Folder ini sudah disertakan dalam sinkronisasi folder induk, sehingga dapat menyebabkan unggahan ganda. + Menunggu Wi-Fi untuk mulai mengunggah + Mengunggah berkas dari %s ke %s Konfigurasi Buat kostum folder baru. Pasang folder kostum. @@ -130,6 +138,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Pilih gawit Bersihkan Gagal membersihkan notifikasi + Bersihkan status setelah Teks tersalin dari %1$s Tidak ada teks yang disalin ke papan klip. Link tersalin @@ -201,6 +210,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Impor gagal dimulai. Coba lagi Tidak ada berkas ditemukan. Tidak dapat menemukan pencadangan terakhir kamu! + Mendeteksi perubahan konten Disalin ke papan klip Terjadi kesalahan ketika mencoba menyalin berkas atau folder ini Tidak memungkinkan untuk menyalin folder kedalam turunannya @@ -248,6 +258,13 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Pilih jenis ekspor Gagal membuat PDF Membuat PDF… + Tidak dapat membuat item di sini: izin buat tidak tersedia. + Tidak dapat membuat berkas: izin tidak mencukupi. + Tidak dapat membuat folder: izin tidak mencukupi. + Tidak dapat menghapus item: izin hapus tidak tersedia. + Tidak dapat memindahkan item: izin pemindahan tidak tersedia. + Tidak dapat membuka item: izin baca tidak tersedia. + Tidak dapat mengganti nama item: izin penggantian nama tidak tersedia. Selesai Jangan dihapus Tidak dapat membuat berkas lokal @@ -327,12 +344,14 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Error saat mengomentari file %1$s crash Galat saat membuat berkas dari templat + Tidak dapat mengambil sharees. Kesalahan menampilkan aksi berkas Error saat mengubah status kunci file Laporan Laporkan masalah ke tracker? (membutuhkan akun GitHub) Galat saat menerima file Error saat mengambil template + Kesalahan saat mengatur pesan status! Galat saat menampilkan dialog penyiapan enkripsi! Galat saat mengaktifkan kamera Galat memulai pemindaian dokumen @@ -361,6 +380,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Transfer Unduh Unggah + Anda tidak memiliki izin untuk membuat atau mengunggah berkas di folder ini. Berbagi eksternal Tambahkan atau unggah Gagal memberikan berkas ke pengelola unduhan @@ -376,6 +396,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Anda tidak dapat membagi, opsi berbagi sudah aktif dari pengguna ini. Tidak ada aplikasi yang tersedia untuk memilih kontak Gagal memuat detil + Silakan pilih izin khusus Berkas Simpan Unggah beberapa berkas atau sinkronisasi dengan perangkat anda. @@ -393,6 +414,8 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Berkas dan folder yang Anda bagikan akan muncul di sini. Belum ada yang dibagikan. Tidak ada hasil yang ditemukan untuk kueri Anda + Mulai pencarian Anda + Ketik di bilah pencarian di atas untuk mencari berkas, kontak, acara kalender, dan lainnya di seluruh akun Anda. folder LANGSUNG Memuat… @@ -437,6 +460,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Berkas tidak dapat disinkronkan. Menampilkan versi terbaru yang tersedia. Ubah nama Unggahan gagal. Tidak ada koneksi internet + %s sudah ada, tidak ada konflik yang terdeteksi Kesalahan terjadi saat memulihkan versi file! Berhasil memulihkan versi berkas. Detail @@ -456,6 +480,11 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Direktori sudah ada Folder ini lebih baik dilihat di %1$s. Buat + %1$d dari %2$d · %3$s + Terjadi kesalahan selama proses sinkronisasi folder %s + Ruang disk tidak mencukupi, sinkronisasi dibatalkan + %s folder berhasil disinkronkan + Menyinkronkan… Tidak ada folder Nama folder tidak bisa kosong Pilih @@ -513,6 +542,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Pencadangan terakhir: %1$s Tautan Nama Tautan + Tautan tidak dapat diakses karena pengaturan keamanan. Pengeditan Daftar tampilan Muat lebih banyak hasil @@ -542,7 +572,6 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Bersihkan data Pengaturan, database dan sertifikat server dari data %1$s akan dihapus secara permanen. \n\nBerkas terunduh akan tetap terjaga.\n\nProses ini dapat memakan waktu lama. Kelola ruang - Anda telah mencapai batas maksimum unggahan file. Silakan unggah kurang dari 500 file dalam satu waktu. File media tidak dapat di-stream Tidak dapat membaca berkas media Berkas media tidak di encoding dengan benar @@ -591,6 +620,8 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Gagal menjalankan aksi. Tampilkan notifikasi untuk berinteraksi dengan hasil operasi latar belakang Beroperasi di latar belakang + Mendeteksi perubahan pada berkas lokal + Pemantau konten Menampilkan progres pengunduhan Unduhan Menampilkan kemajuan dan hasil sinkronisasi berkas @@ -599,6 +630,8 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Notifikasi umum Kemajuan pemutar musik Pemutar media + Menampilkan kemajuan operasi berkas offline + Operasi offline Tampilkan notifikasi push yang dikirim oleh server: Sebutan dalam komentar, penerimaan berbagi jarak jauh baru, pengumuman yang dipos oleh admin, dll. Notifikasi push Tampilkan kemajuan unggahan @@ -613,8 +646,12 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Bahkan tanpa koneksi internet, Anda tetap dapat mengatur folder dan membuat file. Setelah Anda kembali online, semua tindakan yang tertunda akan disinkronkan secara otomatis. Anda offline, tapi kerja tetap lanjut File masih belum tersedia. Tolong unggah filenya terlebih dahulu. + Tidak dapat membuat %s. Berkas dengan nama yang sama sudah ada di server. + Tidak dapat membuat %s. Folder dengan nama yang sama sudah ada di server. Operasi offline tidak dapat diselesaikan. %s Operasi Offline + Penghapusan %s dibatalkan. Berkas tersebut telah diubah di server. + Pemberian nama ulang %s dibatalkan. Berkas dengan nama yang sama sudah ada di server. Memulai operasi offline 1 jam Online @@ -640,11 +677,6 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Tidak ada aplikasi untuk menyetel gambar yang ditemukan. Sematkan ke tampilan beranda Buka %1$s - 389 KB - 12:23:45 - Baru saja diedit - Ini adalah placeholder - 18/05/2012 12:23 PM hentikan alihkan Silahkan pilih server... @@ -789,6 +821,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Perangkat ini kemungkinan tidak terhubung ke internet Gunakan sebagai TIdak dapat mengatur batas unduhan. + Atur pesan Atur catatan Status online Gunakan gambar sebagai @@ -970,6 +1003,7 @@ Berikut ini adalah daftar berkas lokal, dan berkas jarak jauh di %5$s yang terhu Acara di kalender tidak ditemukan, Anda selalu dapat menyinkronkan untuk memperbarui. Mengarahkan ke web… Kontak tidak ditemukan, Anda selalu dapat menyinkronkan untuk memperbarui. Mengarahkan ke web… Izin diperlukan untuk melihat hasil pencarian dan kalau tidak, akan diarahkan ke web... + Di dalam folder ini Tidak diketahui Buka kunci berkas Ada komentar yang belum dibaca @@ -1018,6 +1052,10 @@ Berikut ini adalah daftar berkas lokal, dan berkas jarak jauh di %5$s yang terhu Berkas tidak dapat disalin ke penyimpanan lokal Penguncian folder gagal Unggahan dibatalkan oleh pengguna + Izinkan akses ke semua berkas + Izin aplikasi + Berkas Anda tidak dapat diunggah tanpa akses ke penyimpanan lokal. Ketuk untuk memberikan izin. + Unggah dihentikan – Izin penyimpanan diperlukan %1$d / %2$d - %3$s Enkripsi hanya bisa dengan > = Android 5.0 Ruang tidak cukup untuk menyalin berkas terpilih ke folder %1$s. Apakah Anda ingin memindahkannya? diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 5acfddd..9fb05b2 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -10,6 +10,7 @@ Breyta Hreinsa allar tilkynningar Tæma ruslið + Senda/Deila Reitasýn Listasýn Endurheimta tengiliði og dagatal @@ -44,13 +45,16 @@ Leita í %s Birtast ótengt Bæta við nýju verki + Þú getur búið til nýtt verk neðst til hægri Skrifaðu einhvern texta Ertu viss um að þú viljir eyða þessu verki? Eyða verki + Hleð inn verkefnalista… Villa kom upp við að búa til verkið Tókst að búa til verk Villa kom upp við að eyða verkinu Tókst að eyða verki + Verkefnalisti er tómur. Ekki er hægt að sækja verkefnalista, athugaðu nettenginguna þína. Eyða verki Yfirlitið yfir verkin er ekki tilbúið. @@ -89,11 +93,17 @@ %1$s styður ekki fjölaðganga Gat ekki komið á tengingu Hætta við innskráningu + Settu inn gilt vistfang netþjóns. + Tekst ekki að sækja nánari upplýsingar um innskráningu. Reyndu aftur síðar. + Upp kom vandamál við vinnslu á innskráningarbeiðnarinnar þinnar. Reyndu aftur síðar. + Enginn vafri er ekki tiltækur sem getur opnað þennan tengil. Ljúktu innskráningarferlinu í vafranum þínum haldið áfram í upprunalegri möppu, því hún er skrifvarin Aðeins senda inn á gjaldfrjálsu WiFi-neti /SjálfvirkInnsending + Þessa möppu er nú þegar verið að samstilla úr yfirmöppu, sem getur valdið margföldum innsendingum + Sendi inn skrár frá %s til %s Stilla Búa til nýja nýja sérsniðna uppsetningu á möppum Settu upp sérsniðna möppu @@ -181,6 +191,7 @@ Ertu viss um að þú viljir eyða völdum atriðum og innihaldi þeirra? Einungis staðvært Ekki er hægt að búa til glugga til að leysa árekstra + Árekstrar milli mappa Skrá á tölvunni Ef þú velur báðar útgáfur, þá mun verða bætt tölustaf aftan við heiti afrituðu skrárinnar. Ef þú velur báðar útgáfur, þá mun verða bætt tölustaf aftan við heiti afrituðu möppunnar. @@ -193,6 +204,7 @@ Taka öryggisafrit núna Öryggisafritun er á áætlun og mun hefjast fljótlega Innflutningur er á áætlun og mun hefjast fljótlega + Mistókst að hefja innflutning. Reyndu aftur Engin skrá fannst Gat ekki fundið síðasta öryggisafritið þitt! Afritað á klippispjald @@ -207,6 +219,7 @@ Tókst ekki að ná í slóð Búa til gat ekki búið til möppuna %1 + Búa til tengil Nýtt Nýtt skjal Ný mappa @@ -241,6 +254,13 @@ Veldu tegund útflutnings Gerð PDF tókst ekki Útbý PDF… + Get ekki útbúið atriði hér: vantar búa-til heimild. + Get ekki útbúið skrá: vantar heimild. + Get ekki útbúið möppu: vantar heimild. + Get ekki eytt atriði: vantar eyða-heimild. + Get ekki fært atriði: vantar færa-heimild. + Get ekki opnað atriði: vantar lesa-heimild. + Get ekki endurnefnt atriði: vantar endurnefna-heimild. Lokið Ekki hreinsa Get ekki búið til skrá á tölvu @@ -297,6 +317,7 @@ Setja upp dulritun Afkóðun Loka + Settu inn lykilsetninguna þína til að komast í skrárnar þínar Þessi mappa er ekki tóm. Útbý nýja dulritunarlykla… Öll 12 orðin saman gera mjög sterkt lykilorð, þannig að einungis þú getur skoðað og notað dulrituðu skrárnar þínar. Endilega skrifaðu þau niður og geymdu á öruggum stað. @@ -319,12 +340,14 @@ Villa við að gera athugasemd við skrá %1$s hrundi Villa við að búa til skrá út frá sniðmáti + Mistókst að sækja sameignir. Villa við birtingu skráaaðgerða Villa við að breyta stöðu læsingar á skrá Skýrsla Tilkynna um vandamál í verkbeiðnakerfið? (krefst GitHub-aðgangs) Villa við að ná í skrá Villa við að ná í sniðmát + Villa kom upp við að setja stöðuskilaboð! Villa við að birta uppsetningarglugga dulritunar! Villa við að ræsa myndavél Villa við að ræsa skönnun skjals @@ -345,6 +368,7 @@ Stöðva prufuverk Yfirfærslur (uppfærsla forrits) Kjörstillingar + Prófunarhamur forritara Skráaflutningur Setja niðurhalsprófun í biðröð Setja innsendingarprófun í biðröð @@ -352,6 +376,7 @@ Færa Sækja Senda inn + Þú hefur ekki réttindi til að búa til eða senda inn skrár í þessari möppu. Utanaðkomandi sameignir Bæta við eða senda inn Mistókst að senda skrá í niðurhalsstýringu @@ -364,8 +389,10 @@ Skráarheitið er þegar til staðar Eyða Villa við að ná í virkni fyrir skrá + Þú getur ekki tekið búið til sameign, deiling er þegar virk af hálfu þessa notanda. Ekkert forrit tiltækt til að velja tengiliði Mistókst að hlaða inn ítarupplýsingum + Veldu sérsniðna heimild Skrá Halda Sendu inn eitthvað efni eða samstilltu við tækin þín! @@ -383,6 +410,8 @@ Skrár og möppur sem þú deilir birtast hér. Engu deilt ennþá Engar niðurstöður fundust fyrir leitina þína + Byrja að leita + Skrifaðu í leitarstikuna fyrir ofan til að finna skrár, tengiliði, atburði í dagatali og fleira á öllum aðgangnum þínum. mappa BEINT Hleð inn… @@ -423,9 +452,11 @@ %s er bannað sem heiti %s. Endurnefndu skrána áður en fært er eða afritað Skrá fannst ekki + Skrá fannst ekki. Gat ekki búið til sameign. Ekki tókst að samstilla skrána. Birti nýjustu tiltæku útgáfuna. Endurnefna Innsending mistókst. Engin internettenging + %s er þegar til, engir árekstrar fundust Villa við að endurheimta útgáfu skráar! Tókst að endurheimta útgáfu skráar. Nánar @@ -443,6 +474,7 @@ Auðlærður vefpóstur, dagatal og tengiliðir Skjádeiling, netfundir og vefráðstefnur Mappa er þegar til staðar + Þessa möppu er best að skoða í %1$s. Búa til Engar möppur hér Möppuheiti má ekki vera tómt @@ -501,6 +533,7 @@ Síðasta öryggisafrit: %1$s Tengill Heiti tengils + Ekki var farið eftir tengli vegna öryggisstillinga. Breytingar Framsetning sem listi Hlaða inn fleiri niðurstöðum @@ -530,7 +563,6 @@ Hreinsa gögn Stillingum, gagnagrunni og skilríkjum vefþjóns úr gögnum %1$s verður eytt endanlega. \n\nSóttar skrár verða ekki snertar.\n\nÞetta ferli getur tekið drjúga stund. Sýsla með geymslurými - Þú hefur náð hámarksfjölda innsendra skráa. Sendu inn færri en 500 skrár í einu. Ekki tókst að streyma margmiðlunarskrá Gat ekki lesið margmiðlunarskrána Margmiðlunarskráin er með ranga kóðun @@ -561,7 +593,8 @@ Ný athugasemd Fann möppu með %1$s. mynd - Myndskeið + myndskeið + Ný tilkynning Ný útgáfa var útbúin Engar aðgerðir fyrir þennan notanda Ekkert forrit tiltækt til að meðhöndla tengla @@ -577,6 +610,7 @@ Táknmynd fyrir athugasemd Mistókst að framkvæma aðgerð. Birta tilkynningar til að meðhöndla niðurstöður bakgrunnsaðgerða + Bakgrunnsaðgerðir Sýnir framvindu niðurhals Sótt gögn Sýnir framvindu og niðurstöður samstillingar skráa @@ -585,6 +619,8 @@ Almennar tilkynningar Framvinda margmiðlunarspilara Margmiðlunarspilari + Sýnir framvindu ónettengdra aðgerða + Ónettengdar aðgerðir Birta tilkynningar sem ýtt er út frá þjóninum: Ummæli (mentions) í athugasemdum, móttaka nýrra sameigna, tilkynningar sendar inn af stjórnendum, o.fl. Ýti-tilkynningar Sýnir framvindu innsendingar @@ -593,15 +629,23 @@ Fyrirliggjandi eru ólesnar tilkynningar Engar tilkynningar Athugaðu aftur síðar. + Aðgerð í bið Fjarlægingaraðgerð í bið Engin internettenging Jafnvel án tengingar við internetið geturðu skipulagt möppurnar þínar og útbúið skrár. Um leið og tenging næst aftur, munu aðgerðirnar þínar sem verið hafa í bið, samstillast sjálfkrafa við netþjóninn. + Tækið er ekki tengt við netið, en vinnan heldur áfram Skráin er ekki til, ennþá. Sendu fyrst inn skrána. + Ekki tókst að búa til %s. Skrá með sama heiti er þegar til á netþjóninum. + Ekki tókst að búa til %s. Mappa með sama heiti er þegar til á netþjóninum. Ekki tókst að ljúka ónettengdu aðgerðinni. %s Ónettengdar aðgerðir + Hætti við eyðingu á %s. Skránni hefur þegar verið breytt á netþjóninum. + Hætti við endurnefningu á %s. Skrá með sama heiti er þegar til á netþjóninum. + Byrja ónettengdar aðgerðir 1 klukkustund Á netinu Staða á netinu + Opnar eftir %1$s Netþjónninn er kominn að endimörkum líftíma síns, endilega uppfærðu hann! Valmynd með fleiru Settu inn lykilkóða @@ -620,13 +664,8 @@ Krafist er viðbótarheimilda til að senda inn og sækja skrár. Veldu tengilið til að deila með Engin forrit fundust til að setja mynd + Festa á upphafsskjá Opna %1$s - .txt - KB - 12:23:45 - Nýlega breytt - Þetta er frátökutákn - e.h. stöðva víxla Veldu netþjón… @@ -643,10 +682,12 @@ Bæta við notandaaðgangi Samstilla dagatal og tengiliði Hvorki F-Droid né Google Play eru uppsett + Setja upp DAVdroid (áður þekkt sem DAVdroid)(v1.3.0+) fyrir þennan aðgang Samstillingar dagatals og tengiliða eru uppsettar Um hugbúnaðinn Nánar Dev + Skráaforrit Almennt Meira Samstilla @@ -654,6 +695,7 @@ Daglegt öryggisafrit af tengiliðum Staðsetning gagnageymslu Sýsla með staðsetningu gagnageymslu + Óvænt villa við að setja upp DAVx⁵ (áður þekkt sem DAVdroid) Enda-í-enda dulritun er virk! E2E minnistækni Til að birta minnishjálp skaltu virkja auðkenningu tækisins. @@ -754,6 +796,7 @@ Sjálfvirk innsending fyrir myndirnar þínar og myndskeið Dagatal og tengiliðir + Samstilla við DAVx⁵ Villa við að sækja leitarniðurstöður Örugg deiling er ekki sett upp fyrir þennan notanda Örugg sameign… @@ -762,13 +805,21 @@ Veldu eitt sniðmát Veldu sniðmát Senda + Senda sameign Táknmynd á sendihnappi + Gat ekki hlaðið inn efni + Tækið er líklega ekki tengt við internetið Setja sem + Ekki er hægt að setja niðurhalsmörk. Athugaðu getuupplýsingar. + Setja skilaboð + Setja minnispunkt Staða á netinu Nota mynd sem Við uppsetningu á enda-í-enda dulritun muntu fá runu 12 tilviljanakenndra orða, sem þú munt þurfa til að opna skrárnar þínar á öðrum tækjum. Þetta mun einungis vera geymt á þessu tæki; hægt er að birta rununa aftur á þessum skjá. Skrifaðu hana niður og geymdu á öruggum stað! Deila Leyfa niðurhal og samstillingu + Við gátum ekki uppfært sameignina. Bættu við minnispunkti og reyndu aftur. + Deila og afrita tengil Búa til Sérsniðnar heimildir Eyða @@ -789,19 +840,22 @@ til að deila þessari skrá Settu inn valkvætt lykilorð Settu inn lykilorð - Tengill á sameign(%1$s) + Tengill á sameign (%1$s) Setja gildistíma Skrá lykilorð Endurdeiling er ekki leyfð á meðan öruggri sleppingu stendur + Veldu a.m.k. einn valkost deilingar áður en þú heldur áfram. Getur breytt Beiðni um skrá Örugg slepping skráa Einungis skoða + Heimildir sameignar Deila Lesa %1$s (fjartengt) %1$s (samtal) Nafn, skýjasambandsauðkenni eða tölvupóstfang … + Bæta við notendum og teymum Senda nýjan tölvupóst Minnispunktur til viðtakanda Stillingar @@ -825,6 +879,8 @@ Skráðu þig hjá þjónustu Leyfa %1$s að að fá aðgang að %2$s Nextcloud-aðgangnum þínum? Raða eftir + Raða eftirlætum fremst + Raða möppum á undan skrám Fela Nánar Ekki tókst að sannvotta auðkenni þjónsins @@ -863,6 +919,7 @@ Fullur aðgangur Skrifvarinn gagnamiðill Ljósmyndir + Sjálfhýsta Nextcloud-kerfið er opið og frjálst, og heldur þér við stjórnvölinn.\n\nEiginleikar:\n* Auðvelt, nútímalegt viðmót, sem hentar þema netþjónsins þíns\n* Sendu skrár inn á Nextcloud-þjóninn þinn\n* Deildu þeim með öðrum\n* Haltu eftirlætisskránum þínum og möppum samstilltum\n* Leitaðu í öllum möppum á netþjóninum þínum\n* Sjálfvirk innsending á myndum og myndskeiðum sem þú tekur á snjalltækinu þínu\n* Haltu öllu uppfærðu í gegnum tilkynningar\n* Styður marga aðganga fyrir hvern notanda\n* Tryggðu aðgang að gögnunum þínum með fingrafari eða PIN-númeri\n* Samþætting við DAVx⁵ (áður þekkt sem DAVdroid) fyrir auðvelda uppssetningu á samstilltu dagatali og tengiliðum\n\nTilkynntu öll vandamál á https://github.com/nextcloud/android/issues og ræddu um þennan hugbúnað á https://help.nextcloud.com/c/clients/android\n\nEr Nextcloud nýtt fyrir þér? Nextcloud er þinn eiginn netþjónn til að samstilla skrár og deila í samstarfs og samskiptaumhverfi. Þetta er frjáls hugbúnaður sem þú getur hýst sjálf/ur eða borgað þjónustuaðila fyrir að gera fyrir þig. Með þessu ert það þú sem ert við stjórnvölinn með gögnin sem þú vilt hafa aðgengileg í gegnum netið, hvort sem það eru ljósmyndir, dagatal, tengiliðir, skjölin þín, og margt fleira.\n\nSkoðaðu Nextcloud á https://nextcloud.com Sjálfhýsta Nextcloud-kerfið er opið og frjálst, og heldur þér við stjórnvölinn.\nÞetta er opinbera þróunarútgáfan, sem kemur með daglegan skammt af óprófuðum nýjum eiginleikum, sem aftur gætu mögulega valdið truflunum og gagnatapi. Forritið er fyrir þá notendur sem vilja taka þátt í að prófa og tilkynna um villur, ef þær eiga sér stað. Ekki nota þetta í alvöru vinnuumhverfi!\n\nBæði opinbera þróunarútgáfan og venjulega útgáfan eru tiltækar á F-droid, og er hægt að setja þær upp samhliða hvorri annarri. Sjálfhýsta Nextcloud-kerfið er opið og frjálst, og heldur þér við stjórnvölinn Sjálfhýsta Nextcloud-kerfið er opið og frjálst, og heldur þér við stjórnvölinn (forútgáfa fyrir hönnuði) @@ -879,8 +936,10 @@ Einungis myndskeið Stinga upp á Samstilla + Samstilla samt Árekstrar fundust - Mappan %1$ser ekki lengur til + Mappan %1$s er ekki lengur til + Margtekin samstilling Gat ekki samstillt %1$s Rangt lykilorð fyrir %1$s Skrár merktar fyrir samstillingu mistókust @@ -897,6 +956,7 @@ Ekki nægilegt pláss Hnappur fyrir stöðu samstillingar Skrár + Aðvörunarhnappur samstillingar Stillingahnappur Stilla möppur Beinar innsendingar hafa verið algerlega endurhannaðar. Endurstilltu sjálfvirkar innsendingar beint í aðalvalmyndinni\n\nNjóttu góðs af nýju og ítarlegu viðmóti sjálfvirkra innsendinga. @@ -979,6 +1039,10 @@ Skrá var ekki hægt að afrita í staðværa gagnageymslu Læsing möppu mistókst Notandi hætti við innsendingu + Leyfa aðgang að öllum skrám + Heimildir forrits + Ekki er hægt að senda inn skrárnar þínar án aðgangs að gagnageymslu á tækinu. Ýttu til að gefa heimild. + Innsending stöðvuð - Krafist heimildar til aðgangs að gagnageymslu %1$d / %2$d - %3$s Dulritun er aðeins möguleg með >= Android 5.0 Ónógt pláss hamlar því að hægt sé að afrita valdar skrár í %1$s möppuna. Viltu færa þær þangað í staðinn? @@ -1075,6 +1139,18 @@ %d mínúta %d mínútur + + fyrir %d sekúndu síðan + fyrir %d sekúndum síðan + + + fyrir %d mínútu síðan + fyrir %d mínútum síðan + + + fyrir %d klukkustund síðan + fyrir %d klukkustundum síðan + Ekki var hægt að samstilla %1$d skrá (árekstrar: %2$d) Ekki var hægt að samstilla %1$d skrár (árekstrar: %2$d) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fa9886a..04bd832 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -531,7 +531,6 @@ Cancella i dati Impostazioni, database e certificati del server dai dati di %1$s saranno eliminati definitivamente.\n\nI file scaricati non saranno interessati.\n\nQuesto processo può richiedere del tempo. Gestisci lo spazio - Hai raggiunto il limite massimo di caricamento file. Carica meno di 500 file alla volta. Il file multimediale non può essere trasmesso Impossibile leggere il file multimediale Il file multimediale ha una codifica non corretta @@ -623,12 +622,6 @@ Seleziona il contatto con cui condividere Nessuna applicazione trovata per impostare un\'immagine Apri %1$s - .txt - 389 KB - 12:23:45 - Modificati di recente - Questo è un segnaposto - 2012/05/18 12:23 PM ferma attiva Per favore, seleziona un server... @@ -649,6 +642,7 @@ Informazioni Dettagli Sviluppo + File Generale Altro Sincronizzazione @@ -826,6 +820,8 @@ Registrati a un fornitore Vuoi consentire a %1$s di accedere al tuo account Nextcloud %2$s? Ordina per + Ordina prima i preferiti + Ordina cartelle prima dei files Nascondi Dettagli L\'identità del server non può essere verificata diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 7e2618b..e547b1b 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -449,11 +449,6 @@ לדחות נדרשות הרשאות נוספות כדי להעלות ולהוריד קבצים לא נמצא יישומון להגדיר אתו תמונה - 389 ק״ב - 12:23:45 - נערכו לאחרונה - זהו ממלא מקום - 2012/05/18 12:23 PM השבתת בדיקת חיסכון בחשמל גורמת להעלאת קבצים כשהסוללה חלשה! נמחק נשמר בתיקייה מקורית @@ -468,6 +463,7 @@ על אודות פרטים פיתוח + קבצים כללי יותר סנכרון diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 2ecc71d..0fcd6e6 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -8,12 +8,12 @@ アカウントアイコン アカウントが見つかりません! 編集 - 全ての通知を削除 + すべての通知を削除 ゴミ箱を空にする 送信/共有 グリッド表示 リスト表示 - 連絡先とカレンダーを復元 + 連絡先とカレンダーを復元する 新しいフォルダー 移動またはコピー 次で開く @@ -24,12 +24,12 @@ 並び替え アクティブなユーザー まだアクティビティはありません - 追加、変更、共有はまだありません + 追加、変更、共有などのイベントはまだありません 送信 リンク送信… アクティビティ - 新規公開共有リンクを追加 - 新しいセキュアなファイルドロップを追加 + 新しい公開共有リンクを追加する + 新しい安全なファイルドロップを追加 %1$s に追加 ベース URL クリップボードを無効化 @@ -93,12 +93,19 @@ %1$s は複数アカウントをサポートしていません 接続を確立できませんでした ログインをキャンセル + 有効なサーバーアドレスを入力してください。 + ログインの詳細を取得することができませんでした。もう一度やり直してください。 ログイン要求の処理中に問題が発生しました。しばらくたってからもう一度お試しください。 + このリンクを開くブラウザーがありません。 ブラウザでログインプロセスを完了してください + バッテリーセーバーがONのためオートアップロードを中断しています。 読み取り専用のため元のフォルダに残しました + バッテリー残量が低下しているため、アップロードに時間がかかることがあります。 定額制 Wi-Fi でのみアップロード /AutoUpload このフォルダは既に親の同期に含まれているため、重複してアップロードされる可能性があります + アップロードのためWi-Fiに接続されるのを待っています… + ファイルを%sから%sへアップロードしています。 設定 新しいカスタムフォルダセットアップを作成する カスタムフォルダを設定する @@ -202,6 +209,7 @@ インポートを開始できませんでした。もう一度やり直してください ファイルが見つかりません あなたの最後のバックアップを見つけることができませんでした! + コンテンツの変更の検出 クリップボードにコピーされました このファイルまたはフォルダーをコピーする際にエラーが発生しました フォルダをその下のフォルダにコピーすることはできません @@ -249,6 +257,13 @@ エクスポートする種類を選択 PDFの生成に失敗しました PDFを生成中... + 権限が無いため、ここにアイテムを作成することはできません。 + 権限が無いため、ファイルを作成することができません。 + 権限が無いため、フォルダーを作成することができません。 + 削除権限が無いため、項目を削除することができません。 + 移動権限がないため、項目を移動することができません。 + 閲覧権限がないため、項目を開くことができません。 + 名前変更権限がないため、項目の名前を変更することができません 完了 消去しない ローカルファイルが作成できません @@ -328,12 +343,14 @@ コメントファイルのエラー %1$s はクラッシュしました テンプレートからファイルを作成中にエラーが発生しました + 共有を取得できません ファイル表示操作でエラーが発生しました ファイルのロック状態変更エラー 報告 問題を報告しますか? (GitHubのアカウントが必要です) ファイルの取得中にエラーが発生しました テンプレートの取得中にエラーが発生しました + ステータスメッセージの設定中にエラーが発生しました。 暗号化設定ダイアログの表示エラー カメラ起動エラー 文書スキャンの開始エラー @@ -362,6 +379,7 @@ 転送 ダウンロード アップロード + このフォルダーにファイルを作成・アップロードする権限がありません。 外部共有 追加またはアップロード ダウンロードマネージャーにファイルを渡せませんでした @@ -374,8 +392,10 @@ ファイル名が既に存在します 削除 ファイルのアクティビティ取得エラー + 共有を作成できません。このユーザーからの共有はすでに有効になっています。 連絡先を選択するためのアプリが利用できません 詳細のロードに失敗しました + カスタムの権限を選択してください。 ファイル 保持 コンテンツをアップロードするか、デバイスと同期してください。 @@ -393,6 +413,8 @@ 共有したファイルやフォルダーがここに表示されます。 まだ何も共有されていません 検索結果が見つかりませんでした + 検索を開始 + アカウント内のファイル、連絡先、カレンダーの予定などを検索するには、上の検索バーに入力してください。 フォルダー ライブ 読み込み中… @@ -433,9 +455,11 @@ %s は禁止されている名前です %s。 移動またはコピーする前にファイルの名前を変更してください ファイルが見つかりません + 共有を作成することができません。ファイルが見つかりませんでした。 ファイルを同期できませんでした。 最新の利用可能なバージョンを表示します。 名前を変更 アップロードに失敗しました。インターネット接続がありません + %sはすでに存在します。強豪は検出されませんでした。 ファイルバージョンの復元中にエラーが発生しました。 ファイルバージョンが正常に復元されました。 詳細 @@ -455,6 +479,10 @@ フォルダーはすでに存在します このフォルダーは%1$sでの閲覧が最適です 作成 + フォルダー%sの同期中にエラーが発生しました。 + ディスク容量が不足しているため同期を中断しました。 + フォルダー%sの同期に成功しました。 + 同期中… フォルダーがありません フォルダ名を空にすることはできません 選択 @@ -512,6 +540,7 @@ 前回のバックアップ: %1$s リンク リンク名 + セキュリティ設定によりリンクを辿れません 編集中 リスト表示 結果をさらに読み込む @@ -541,7 +570,6 @@ データのクリア %1$s のデータから 設定、データベース、サーバー証明書が完全に削除されます。\n\n ダウンロードされたファイルはそのまま残ります。\n\n 実行中はしばらく時間がかかります。 管理領域 - ファイルアップロードの上限に達しました。一度にアップロードできるファイルは500ファイル以下にしてください。 このメディアファイルはストリーミングできません メディアファイルを読み込めません 不正なエンコードのメディアファイルです @@ -590,6 +618,8 @@ アクションの実行に失敗しました。 バックグラウンド操作の結果に対する通知を表示する バックグランド処理 + ローカルファイルの変更が検出されました。 + コンテンツオブザーバー ダウンロードの進行状況を表示 ダウンロード ファイルの同期の進行状況と結果を表示します @@ -598,6 +628,8 @@ 一般的な通知 ミュージックプレーヤーの進行状況 メディアプレーヤー + オフラインファイル操作の進捗を表示する + オフラインファイル操作 サーバーからのプッシュ通知を表示: 新規コメント受信、新規リモート共有、管理者からの告知など プッシュ通知 アップロードの進行状況を表示 @@ -612,8 +644,12 @@ インターネット接続がなくてもフォルダの整理やファイルの作成ができます。オンラインに戻ったら、保留中の操作が自動的に同期されます。 オフラインでも作業は続きます ファイルがまだ存在しません。まずファイルをアップロードしてください。 + %sを作成することができません。サーバーにはすでに同じ名前のファイルが存在します。 + %sを作成することができません。サーバーにはすでに同じ名前のフォルダーが存在します。 オフライン操作を完了できません。%s オフライン操作 + %s の削除をキャンセルしました。ファイルはサーバー上で変更されています。 + 名前を%sへ変更することができません。サーバーにはすでに同じ名前のファイルが存在します。 オフライン操作を開始しています 1時間 オンライン @@ -639,13 +675,6 @@ 画像を設定するアプリが見つかりませんでした ホームスクリーンにピン留めする %1$sを開く - .txt - 389 KB - プレースホルダー - 12:23:45 - 最近編集したもの - これはプレースホルダです - 2012/05/18 PM 12:23 中止 切替え サーバを選択してください... @@ -662,10 +691,12 @@ アカウントを追加 カレンダーと連絡先を同期 F-Droid も Google Play もインストールされていません + DAVx⁵ (旧称 DAVdroid) (v1.3.0+) を現在のアカウントに設定する カレンダーと連絡先の同期セットアップ アプリについて 詳細 開発者 + ファイル 一般 もっと見る 同期 @@ -673,6 +704,7 @@ 連絡先のデイリーバックアップ データの保存場所 データの保存場所を管理する + DAVx⁵ (旧称 DAVdroid) の設定中に予期しないエラーが発生しました。 End-to-end 暗号化を設定中! E2Eニーモニック ニーモニックを表示するには、デバイスクレデンシャルを有効にしてください。 @@ -773,6 +805,7 @@ 自動アップロード あなたの写真とビデオのために カレンダーと連絡先 + DAVx⁵で同期 検索結果の取得中にエラーが発生しました このユーザのためにセキュアな共有が設定されていません セキュアシェア... @@ -783,13 +816,18 @@ 送信 送信/共有 送信ボタンアイコン + コンテンツをロードできませんでした + デバイスがインターネットに接続されていないようです として設定され + ダウンロードの上限を設定できません。容量を確認してください。 + メッセージを設定 ノート オンラインステータス 画像を使用する End-to-End 暗号化の設定中に、12語のランダムなニーモニックが表示されます。これは他のデバイスでファイルを開くために必要です。この情報はこのデバイスにのみ保存され、この画面で再表示することができます。安全な場所にメモしておいてください! 共有 ダウンロードと同期を許可 + 共有を更新できませんでした。ノートを追加してもう一度やり直してください。 リンクをコピーして共有 作成 カスタム権限 @@ -815,6 +853,7 @@ 有効期限を設定 パスワードを設定 セキュアファイルドロップ中は再共有できません。 + 最低1つのオプションを選んでください。 編集可能 ファイルリクエスト セキュアなファイルドロップ @@ -849,6 +888,8 @@ 他のサービスでサインアップ %1$s があなたのNextcloudアカウント %2$s にアクセスできるようにしますか? ソート + お気に入りを最初に並べる + ファイルよりもフォルダを先に並べ替えます 非表示 詳細 サーバーIDを確認できません @@ -1006,6 +1047,10 @@ ローカルストレージへにファイルをコピーできませんでした フォルダのロックに失敗しました アップロードはユーザーによってキャンセルされました + 全てのファイルへのアクセスを許可 + アプリ権限 + ファイルのアップロードにはローカルストレージへのアクセス権限が必要です。タップして許可してください。 + アップロード停止中 ― ストレージへのアクセス権限が必要です %1$d / %2$d - %3$s 暗号化は、Android 5.0以降で使えます。 十分な空き容量が無い場合、選択したファイルは%1$sにコピーできません。コピーせず移動にしますか? diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 8c31632..5bf5ffc 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -532,11 +532,6 @@ Additional permissions required to upload and download files. No app found to set a picture with Open %1$s - .txt - 389 KB - 12:23:45 - This is a placeholder - 2012/05/18 12:23 PM stop toggle Disabling power save check might result in uploading files when in low battery state! @@ -705,6 +700,7 @@ Sign up with provider Allow %1$s to access your Nextcloud account %2$s? Sort by + Sort favorites first Hide Details The identity of the server could not be verified diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3313fb5..c59e9b9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -534,7 +534,6 @@ 데이터 지우기 %1$s의 데이터에서 설정, 데이터베이스, 서버 인증서를 삭제합니다.\n\n다운로드한 파일은 변경되지 않습니다.\n\n이 작업은 시간이 걸릴 수 있습니다. 저장 공간 관리 - 최대 파일 업로드 제한에 도달했습니다. 1회 당 500개 이하의 파일을 업로드해 주세요. 미디어 파일을 스트리밍 할 수 없습니다. 미디어 파일을 읽을 수 없음 미디어 파일의 인코딩이 잘못됨 @@ -627,11 +626,6 @@ 공유할 연락처를 선택 사진을 설정할 앱을 찾을 수 없음 %1$s 열기 - 389 KB - 12:23:45 - 최근에 편집됨 - 이것은 자리 비움자입니다 - 2012년 05월 18일 오후 12:23 멈추기 토글 서버를 선택해 주세요... @@ -652,6 +646,7 @@ 정보 자세한 정보 개발 + 파일 일반 더 보기 동기화 @@ -829,6 +824,8 @@ 공급자로 가입 %1$s이(가) Nextcloud 계정%2$s에 액세스하도록 허용 하시겠습니까? 정렬 + 즐겨찾기를 처음에 나열 + 폴더를 파일보다 먼저 정렬 숨기기 자세히 서버를 검증할 수 없습니다 diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index 3902c0e..939e048 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -10,9 +10,12 @@ ແກ້ໄຂ ລ້າງການແຈ້ງເຕືອນທັງຫມົດ ລ້າງຖັງຂີ້ເຫຍື່ອ + Send/share ເບິ່ງຕາຕະລາງ ລາຍການທີ່ຈະເບິ່ງ + Restore contacts and calendar ໂຟນເດີໃຫມ່ + Move or copy ເປີດດ້ວຍ ຄົ້ນຫາ ລາຍລະອຽດ @@ -28,8 +31,39 @@ ເພິ່ມການແບ່ງປັນສາທາລະນະໃໝ່ ຕື່ມການວາງໄຟລ໌ທີ່ປອດໄພໃໝ່ ເພີ່ມໃສ່ %1$s + Base URL + Disable Clipboard + Disable Intro + Disable Log + Disable External Sites + Disable Multi Account + Disable Sharing + Enforce Protection + Proxy Hostname + Proxy Port ສະແດງໜຶ່ງລາຍການຈາກໜ້າປັດ ຄົ້ນຫາໃນ%s + Appear offline + This content was generated by AI and can make mistakes. + Add new task + Create a new task from bottom right + Type some text + Are you sure you want to delete this task? + Delete task + Loading task list… + An error occurred while creating the task + Task successfully created + An error occurred while deleting the task + Task successfully deleted + Task list is empty. + Task list is empty. Check assistant app configuration. + Unable to fetch task list, please check your internet connection. + Delete Task + The task output is not ready yet. + Unable to fetch task types, please check your internet connection. + Assistant + Input + Output ບໍ່ພົບບັນຊີທີ່ກ່ຽວຂ້ອງ! ການເຂົ້າເຖິງໄດ້ບໍ່ສຳເລັດ%1$s: ຍັງບໍ່ໄດ້ເພີ່ມບັນຊີໃສ່ໃນອຸປະກອນນີ້ @@ -60,9 +94,20 @@ ບໍ່ສາມາດຊອກຫາເຈົ້າພາບໄດ້ %1$sບໍ່ສະຫນັບສະຫນູນຫຼາຍບັນຊີ ບໍ່ສາມາດສ້າງການເຊື່ອມຕໍ່ + Cancel Login + Please enter a valid server address. + Unable to fetch login details. Please try again. + There was an issue processing your login request. Please try again later. + No browser is available to open this link. + Please complete login process in your browser + Auto-upload is paused because Battery Saver is on. ເກັບໄວ້ໃນໂຟນເດີດັ້ງເດີມ, ເປັນຮຸບແບບອ່ານຢ່າງດຽວ + Low battery, upload might take longer ພຽງແຕ່ອັບໂຫຼດໃນ Wi-Fi ເທົ່ານັ້ນ /ອັບໂຫຼດອັດຕະໂນມັດ + This folder is already included in the parent folder’s sync, which may cause duplicate uploads + Waiting for Wi-Fi to start uploading + Uploading files from %s to %s ຕັ້ງຄ່າ Config ສ້າງການຕັ້ງຄ່າໂຟນເດີດ້ວຍຕົນເອງ ກຳໜົດການຕັ້ງຄ່າໂຟນເດີດ້ວຍຕົນເອງ @@ -71,22 +116,30 @@ ຮຸບພາບອາວະຕານ ອອກໄປ ສໍາຮອງຂໍ້ມູນການຕັ້ງຄ່າ + Contacts and calendar backup ປີດ ປີດ ອຸປະກອນຂອງທ່ານອາດຈະເປີດໃຊ້ງານປະສິດທິພາບ. ການອັບໂຫຼດອັດຕະໂນມັດ ເພື່ອເຮັດວຽກໄດ້ຢ່າງຖືກຕ້ອງ. ການເພີ່ມປະສີດທິພາບແບັດເຕີລີ + Assistant ລາຍການທີ່ມັກ ຟາຍທັງໝົດ ຊື່ມວນຊົນ + Busy ປະຕິທິນ + Calendars ມີບັນຫາໃນການໂຫຼດການຢັ້ງຢືນ ເວີຊັ້ນຂອງ Changelog + Check back later or reload. ເຄື່ອງໝາຍກ່ອງ ເລືອກໂຟນເດີພາຍໃນເຄື່ອງ... ເລືອກໂຟນເດີໄລຍະໄກ... ກະລຸນາເລືອກແບບຟອມ ແລະ ໃສ່ຊື່ຟາຍ ເລືອກເອົາຟາຍທີ່ຈະເກັບໄວ້! + Choose widget + ລຶບລ້າງ ລ້າງການແຈ້ງເຕືອນບໍ່ສໍາເລັດ. + Clear status after ສໍາເນົາຂໍ້ຄວາມຈາກ%1$s ບໍ່ໄດ້ຮັບຂໍ້ຄວາມທີ່ຈະສໍາເນົາໄປຍັງຄລິບ ສຳເນົາລິງ @@ -101,9 +154,11 @@ ຜິດພາດ ຄວາມຈຳບໍ່ພໍ ຂໍ້ຜິດພາດທີ່ບໍ່ຮູ້ຈັກ + Leave this share ກຳລັງໂຫຼດ... ທັດໄປ ບໍ່ + Now ຕົກລົງ ທີ່ກໍາລັງລໍຖ້າ ລຶບ @@ -133,20 +188,30 @@ ການຊ່ວຍເຫຼືອ ໂດຍການທົດສອບ ລາຍງານບັນຫາກ່ຽວກັບ GitHub ຕັ້ງຄ່າຄອຍຟິກ + Remove local encryption ທ່ານຕ້ອງການລືບແທ້ບໍ %1$s? ທ່ານຕ້ອງການລືບລາຍການທີ່ເລືອກໄວບໍ? ທ່ານຕ້ອງການ ລຶບ%1$s ແລະ ເນື້ອຫາບໍ? ທ່ານຕ້ອງການລຶບລາຍການທີ່ເລືອກ ແລະ ເນື້ອຫາແທ້ບໍ? ຊ່ອງເກັບຢ່າງດຽວ + Conflict resolver dialog cannot be created + Folder conflict + Local file ຖ້າທ່ານເລືອກເອົາທັງສອງເວີຊັ້ນ, ບ່ອນເກັບຟາຍຈະມີຈໍານວນສະສົມ + If you select both versions, the local folder will have a number appended to its name. + Server file + Contacts backup + Contact permission is required. ລາຍການໄອຄອນຜູ້ຕິດຕໍ່ ບໍ່ໄດ້ຮັບອະນຸຍາດ, ບໍ່ມີຫຍັງນໍາເຂົ້າ. ຕິດຕໍ່ ສໍາຮອງຂໍ້ມຸນດຽວນີ້ ການສໍາຮອງທີ່ກໍານົດໄວ້ ແລະ ຈະເລີ່ມຕົ້ນໃນໄວໆນີ້ ການນໍາເຂົ້າທີ່ໄດ້ກໍານົດໄວ້ ແລະ ຈະເລີ່ມໃນໄວໆນີ້ + Import failed to start. Please try again ບໍ່ພົບຟາຍ ບໍ່ສາມາດຊອກຫາ ການສໍາຮອງລ່າສຸດຂອງທ່ານໄດ້! + Detecting content changes ສໍາເນົາຄລິບ ມີຂໍ້ຜິດພາດເກີດຂຶ້ນໃນຂະນະທີ່ພະຍາຍາມສໍາເນົາຟາຍຫຼືໂຟນເດີນີ້ ມັນເປັນໄປບໍ່ໄດ້ທີ່ຈະສໍາເນົາໂຟນເດີ ເປັນຫນຶ່ງໃນໂຟນເດີ ພື້ນຖານ @@ -159,26 +224,54 @@ ບໍ່ສາມາດເກັບURL ໄດ້ ສ້າງ ບໍ່ສາມາດສ້າງໂຟນເດີໄດ້ + Create link + New + New document ໂຟນເດີໃຫມ່ + New presentation + New spreadsheet + Add folder description + Adds folder description ຂໍ້ມູນປະຈໍາຕົວຖືກປິດ + Daily backup + Data to back up ການຢັ້ງຢືນຕົວຕົນທີ່ບໍ່ຖືກຕ້ອງ ຍ້າຍບັນຊີ ລົບລາຍການ + Delete Link ເລືອກຄຶນທັງໝົດ + Destination filename ມີເວີຊັນໃຫມ່ ບໍ້ມີຂໍ້ມູນ ບໍ່ມີເວີຊັ້ນໃຫມ່ວ່າງ. ປິດ + Did not check for duplicates. algorithm ຫຍ່ອຍບໍ່ພ້ອມໃຊ້ງານໃນໂທລະສັບຂອງທ່ານ ເຂົ້າສູ້ລະບົບ ຜ່ານການເຊື່ອມຕໍ່ໂດຍກົງບໍ່ສຳເລັດ! + Login with %1$s to %2$s ປິດ ຍົກເລີກ ຍົກເລີກການເເຈ້ງເຕືອນ + Displays your 12 word passphrase ຫ້າມລົບກວນ + Multiple images + PDF file + Choose export type + PDF generation failed + Generating PDF… + Can’t create items here: missing create permission. + Can’t create file: missing permission. + Can’t create folder: missing permission. + Can’t delete item: missing delete permission. + Can’t move item: missing move permission. + Can’t open item: missing read permission. + Can’t rename item: missing rename permission. ສໍາເລັດ ບໍ່ຈະແຈ້ງ ບໍ່ສາມາດສ້າງຟາຍໄດ້ + Invalid filename for local file ດາວໂຫຼດເວີຊັ້ນ dev ລຸ້ນລ່າສຸດ + Download limit ໂຫຼດບໍ່ໄດ້%1$s ໂຫຼດບໍ່ໄດ້, ເຂົ້າລະບົບໃໝ່ອີກຄັ້ງ ດາວໂຫຼດບໍ່ສຳເລັດ @@ -187,17 +280,23 @@ ກຳລັງດາວໂຫຼດ %1$sດາວໂຫຼດສຳເລັດ ດາວໂຫຼດສຳເລັດ + Certain files were canceled during the download by user + Error occurred while downloading files ຍັງບໍ່ທັນດາວໂຫຼດເທື່ອ + Unexpected error occurred while downloading files ປິດດ້ານຂ້າງ ຊຸມຊົນ ຮູບພາບເບື້ອງຫຼັງຂອງຫົວຂໍ້ ບັນດາກິດຈະກຳ ຟາຍທັງໝົດ + Assistant ລາຍການທີ່ມັກ ຊື່ມວນຊົນ + Groupfolders ໜ້າຫຼັກ ການເເຈ້ງເຕືອນ ເທິງອຸປະກອນ + Personal files ແປງລ່າສຸດ ແບ່ງປັນ ລຶບຟາຍ @@ -207,10 +306,23 @@ ນຳໃຊ້ %1$sຂອງ%2$s ນຳໃຊ້%1$s ອັບໂຫຼດອັດຕະໂນມັດ + %s due to too many wrong attempts + Counter is too old + Hash not found + E2E not yet setup + Not possible without internet connection + Signature does not match + Assistant + More + More Nextcloud Apps + Failed to pick email address. ຕັ້ງເປັນການເຂົ້າລະຫັດ + Unable to retrieve server certificate + Failed to verify public key ຕັ້ງການເຂົ້າລະຫັດ ຖອດລະຫັດລັບ ປິດ + Enter your passphrase to access your files ໂຟນເດີນີ້ເຕັມ. ກຳລັງສ້າງກະແຈໃໝ່ ທັງຫມົດ 12 ຄໍາສັບຮ່ວມກັນເຮັດໃຫ້ລະຫັດຜ່ານມີຄວາມປອດໄພ, ພຽງແຕ່ທ່ານນໍາໃຊ້ຟາຍທີ່ເຂົ້າລະຫັດຂອງທ່ານ. ກະລຸນາຂຽນລົງ ແລະ ຮັກສາໄວ້ບ່ອນໃດບ່ອນຫນຶ່ງໃຫ້ປອດໄພ. @@ -218,20 +330,36 @@ ບັນທຶກການເຂົ້າລະຫັດ 12 ຕົວຂອງທ່ານ ລະຫັດຜ່ານ ການເອີ້ນກະເເຈມາໃຊ້ + Unable to retrieve private key + Unable to retrieve public key ການຈັດເກັບກະເເຈ ຕັ້ງຄ່າການເຂົ້າລະຫັດລັບ + An unexpected error occurred while downloading the keys ບໍ່ສາມາດບັນທຶກໄດ້, ກະລຸນາລອງອີກເທື່ອຫນຶ່ງ. ຜິດພາດໃນຂະນະຖອດລະຫັດລັບ. ລະຫັດບໍ່ຖຶກຕ້ອງ + Enter destination filename ກະລຸນາໃສ່ຊື່ຟາຍ %1$sບໍ່ສາມາດ ສຳເນົາໄປຍັງ %2$sຂອງໂຟນເດິ ພິດພາດ ບໍ່ສາມາດດໍາເນີນການໄດ້ + Error choosing date ຜິດພາດໃນການສະແດງຄວາມຄິດເຫັນ %1$sຢຸດ + Error creating file from template + Unable to fetch sharees. + Error showing file actions + Error changing file lock status ລາຍງານ + Report issue to tracker? (requires a GitHub account) ຜີດພາດໃນການ ດຶງ ຫຼື ເອີ້ນຟາຍມາໃຊ້ ຜິດພາດໃນການດຶງ ແບບຕົວຢ່າງມາໃຊ້ + Error setting status message! + Error showing encryption setup dialog! ຂໍ້ຜິດພາດການເລິິມເປີດກ້ອງ + Error starting document scan + Failed to upload taken media ບັນຊີ + Times run in 48h + ສ້າງເມື່ອ ຊື່ວຽກ ດຳເນີນການ ກ່າວ @@ -245,10 +373,16 @@ ຢຸດທົດລອງວຽກ ການໂອນຍ້າຍ (ການຍົກລະດັບເເອັບ) ການເລືອກ + Engineering test mode + File transfer ການທົດສອບດາວໂຫຼດ Enqueue + Enqueue test upload ເສັ້ນທາງໄລຍະໄກ + Transfer ດາວໂຫລດ ອັບໂຫຼດ + You don’t have permission to create or upload files in this folder. + ການແບ່ງປັນພາຍນອກ ເພີ່ມ ຫຼື ອັບໂຫຼດ ບໍ່ສາມາດຜ່ານຟາຍ ເພື່ອຈັດການດາວໂຫລດ ພິມຟາຍບໍ່ສຳເລັດ @@ -256,27 +390,45 @@ ອັບເດດ UI ບໍ່ສຳເລັດ ເພີ່ມລາຍການທີ່ມັກ ລາຍການທີ່ມັກ + Shared file cannot be updated + Filename already exists ລຶບ ການດຶງຟາຍຂໍ້ມູນຜິດພາດ + You cannot create a share, sharing is already active from this user. + No app available to select contacts ການດາວໂຫຼດລາຍລະອຽດບໍ່ສຳເລັດ + Please select custom permission ຟາຍ ຮັກສາໄວ້ ອັບໂຫຼດເນື້ອຫາບາງອັນ ຫຼື sync ອຸປະກອນຂອງທ່ານ ບໍ່ມີລາຍການທີ່ມັກເທື່ອ ຟາຍ ແລະ ໂຟນເດີລາຍການ ທີ່ທ່ານມັກ ຈະສະແດງໃຫ້ເຫັນ ຢູ່ ທີ່ ນີ້ . + Found no images or videos ບໍ່ມີຟາຍ ບໍ່ມີຜົນ ໃນໂຟນເດີນີ້ ບໍ່ມີຜົນ + No file or folder matching your search ບໍ່ມີຫຍັງໃນນີ້. ທ່ານສາມາດເພີ່ມໂຟນເດີໄດ້. ຟາຍແລະ ໂຟນ ເດີທີ່ດາວໂຫຼດ ຈະສະແດງໃຫ້ເຫັນຢູ່ທີ່ນີ້ . ບໍ່ພົບຟາຍດັດແປງພາຍໃນ 7 ວັນລ່າສຸດ ບາງທີອາດຢູ່ໃນໂຟນເດີທີ່ແຕກຕ່າງກັນ? ຟາຍ ແລະ ໂຟນເດີ ທີ່ ທ່ານແບ່ງປັນຈະສະແດງ ໃຫ້ເຫັນຢູ່ທີ່ນີ້ ຍັງບໍ່ມີການແບ່ງປັນເທື່ອ + No results found for your query + Start your search + Type in the search bar above to find files, contacts, calendar events, and more across your account. + Check your internet connection or try again later + Poor connection ໂຟນເດີ + LIVE ກຳລັງໂຫຼດ ບໍ່ມີແອັບພລິເຄຊັນທີ່ຕັ້ງໄວ້ເພື່ອຈັດການກັບຟາຍປະເພດນີ້. ວິນາທີຜ່ານມາ + Permissions needed + Storage permissions + %1$s works best with permissions to access storage. You can choose full access to all files, or read-only access to photos and videos. + %1$s needs file management permissions to upload files. You can choose full access to all files, or read-only access to photos and videos. + Allow access from other apps ການກວດກາຈຸດຫມາຍປາຍທາງ... ກຳລັງລ້າງ ອັບເດດໂຟນເດີຖານຂໍ້ມຸນ @@ -287,6 +439,7 @@ ບໍ່ສາມາດຂຽນຫາຟາຍຈຸດຫມາຍປາຍທາງໄດ້ ການຍົກຍ້າຍບໍ່ສຳເລັດ ບໍ່ສາມາດປັບປຸງໜ້າ indexໄດ້ + %1$s\n(%2$s / %3$s) ການເຄື່ອນຍ້າຍຂໍ້ມູນ... ສຳເລັດ ປ່ຽນແທນ @@ -298,13 +451,24 @@ ການປັບປຸງ ໜ້າ index ... ໃຊ້ ລໍຖ້າການ sync ແບບເຕັມ... + Current folder name is invalid, please rename the folder. Redirecting home… + Folder path contains reserved names or invalid characters + %s is a forbidden file extension + Filenames must not contain spaces at the beginning or end + Name contains invalid characters: %s + %s is a forbidden name + %s. Please rename the file before moving or copying ບໍ່ພົບຟາຍ + File not found. Unable to create a share. ຟາຍບໍ່ສາມາດ synced ໄດ້ . ສະແດງເວີຊັ້ນທີ່ມີຢູ່ລ່າສຸດ. ປ່ຽນຊື່ + Upload failed. No internet connection + %s already exists, no conflict detected ຂໍ້ຜິດພາດໃນການກູ້ຄືນເວີຊັ້ນຟາຍ! ຟາຍທີ່ ໄດ້ ຮັບການກຸ້ຄືນສໍາເລັດ . ລາຍລະອຽດ ດາວໂຫຼດ + Export ຟາຍຖືກປ່ຽນຊື່ %1$sໃນລະຫວ່າງການອັບໂຫຼດ Sync ບໍ່ໄດ້ເລືອກຟາຍ @@ -313,10 +477,21 @@ ຊື່ຟາຍມີລັກສະນະທີ່ບໍ່ຖືກຕ້ອງ ຊື່ຟາຍ ຮັກສາຂໍ້ມູນຂອງທ່ານໃຫ້ປອດໄພ ແລະ ຢູ່ພາຍໃຕ້ການຄວບຄຸມຂອງທ່ານ + Secure collaboration and file exchange + Easy-to-use webmail, calendar and contacts + Screensharing, online meetings and web conferences ໂຟນເດີມີຢຸ່ແລ້ວ + This folder is best viewed in %1$s. ສ້າງ + %1$d of %2$d · %3$s + An error occurred during synchronization of the %s folder + Insufficient disk space, synchronization canceled + %s folder successfully synchronized + Syncing… ບໍ່ມີໂຟນເດີ + Folder name cannot be empty ເລືອກ + Choose target folder ສຳເນົາ ຍ້າຍ ບໍ່ໄດ້ຮັບອານຸຍາດ%s @@ -333,37 +508,74 @@ ຟາຍທັງໝົດຖືກຍ້າຍ ໄປຂ້າງຫນ້າ 4 ຊົ່ວໂມງ + Google restricted downloading APK/AAB files! + This icon indicates availability of live photo + Name will result in a hidden file ຊື່ ຫມາຍເຫດ ລະຫັດຜ່ານ ເຊີເວີບໍ່ວ່າງ ເຊີເວີຂອງທ່ານເອງ + Icon for empty list + Icon of dashboard widget + Icon of widget entry + edited + Flip horizontally + Flip vertically + Rotate anti-clockwise + Rotate clockwise + Unable to edit image. + File details + Image taking conditions + ƒ/%s + ISO %s + %s MP + %s mm + %s s ອັບໂຫຼດຟາຍທີ່ມີຢູ່ແລ້ວ ພຽງແຕ່ອັບໂຫຼດເທົ່ານັ້ນ /ອັບໂຫຼດທັນທີ + ການແບ່ງປັນພາຍໃນ + Internal two way sync + Not yet, soon to be synced + An internet connection is required to set up the encrypted folder URL ທີ່ບໍ່ຖືກຕ້ອງ ເບິ່ງບໍ່ເຫັນ Label ບໍ່ສາມາດເປົ່າວ່າງໄດ້ + Last backup: %1$s ລິງ + Link Name + Link not followed due to security settings. + Editing ລາຍຊື່ ຜົນLoad ເພີ່ມເຕີມ ບໍ່ມີຟາຍໃນໂຟນເດີນີ້. ຟາຍບໍ່ພົບໃນລະບົບຊ່ອງເກັບຂໍ້ມຸນ %1$s/%2$s ບໍ່ມີໂຟນເດີເພີ່ມເຕີມ. + Locate folder + Expires: %1$s + Lock file + Locked by %1$s + Locked by %1$s app ບັນທຶກແອັບພີເຄເຊິນ%1$s + No app for sending logs found. Please install an email client. + Logged in as %1$s ເຂົ້າລະບົບ ການເຊື່ອມຕໍ່ເວັບໄຊຂອງທ່ານ %1$sເມື່ອທ່ານເປີດມັນໃນເວັບໄຊ ລຶບ Refresh ຊອກຫາການບັນທຶກ + Send logs by email ບັນທຶກ:%1$dkB, ແບບສອບຖາມກົງກັນ%2$d/%3$d ໃນ%4$dວິນາທີ ກຳລັງໂຫຼດ ບັນທຶກ:%1$d kB, ບໍ່ມີຕົວກອງ ບັນທຶກ + Server is in maintenance mode ລ້າງຂໍ້ມູນ ການຕັ້ງຄ່າ, ຖານຂໍ້ມູນ ແລະ ໃບຢັ້ງຢືນເຊີເວີຈາກຂໍ້ມູນ %1$s\'s ຈະຖືກລຶບອອກຢ່າງຖາວອນ. \n\nຟາຍທີ່ຖືກໂຫຼດບໍ່ໄດ້ເກັບຮັກສາໄວ້.\n\nຂະບວນການນີ້ຈະໃຊ້ເວລາໄລຍະຫນຶ່ງ. ຈັດການພື້ນທີ່ + The media file cannot be streamed ບໍ່ສາມາດອ່ານຟາຍສື່ມວນຊົນໄດ້ ຟາຍສື່ມວນຊົນມີ ການ ເຂົ້າລະຫັດບໍ່ຖືກຕ້ອງ ການເຂົ້າຟາຍໝົດເວລາ @@ -385,6 +597,7 @@ ມັນເປັນໄປບໍ່ໄດ້ທີ່ຈະຍ້າຍໂຟນເດີເຂົ້າໄປໃນ ຫນຶ່ງໃນໂຟນເດີພື້ນຖານຂອງມັນເອງ ຟາຍແມ່ນມີຢູ່ແລ້ວໃນໂຟນເດີຈຸດຫມາຍປາຍທາງ ບໍ່ສາມາດເຄື່ອນຍ້າຍຟາຍໄດ້. ກະລຸນາກວດເບິ່ງວ່າມີຢູ່ ຫຼື ບໍ່. + Mute all notifications ມີຂໍ້ຜິດພາດເກີດຂຶ້ນໃນຂະນະທີ່ລໍຖ້າເຊີເວີ. ບໍ່ສາມາດດໍາເນີນການໄດ້ຢ່າງຄົບຖ້ວນ. ເກີດຂໍ້ຜິດພາດໃນລະຫວ່າງການເຊື່ອມຕໍ່ກັບເຊີເວີ ມີຂໍ້ຜິດພາດເກີດຂຶ້ນໃນຂະນະທີ່ລໍຖ້າເຊີເວີ. ບໍ່ສາມາດດໍາເນີນການໄດ້ຢ່າງຄົບຖ້ວນ. @@ -393,13 +606,25 @@ ໂຟນເດີສື່ມວນຊົນໃຫມ່%1$sຖືກກວດພົບ. ຮຸບພາບ ວິດິໂອ + New notification ເວີຊັ້ນໃຫມ່ໄດ້ຖືກສ້າງຂຶ້ນ + No actions for this user ບໍ່ມີແອັບພລິເຄຊັນທີ່ສາມາດຈັດການກັບລິ້ງໄດ້ + No calendar exists + No App available to handle mail address + No items + No App available to handle maps ອະນຸຍາດໃຫ້ບັນຊີດຽວເທົ່ານັ້ນ ບໍ່ມີແອັບ ທີ່ສາມາດຮັບມືກັບ PDF + No app available for sending the selected files + Please select at least one permission to share. ບໍ່ສາມາດສົ່ງຂໍ້ຄວາມໄດ້ ໄອຄອນ ບໍ່ສາມາດດໍາເນີນການໄດ້. + Show notifications to interact result of background operations + Background operations + Detects local file changes + Content observer ສະແດງຄວາມຄືບຫນ້າຂອງດາວໂຫລດ ດາວໂຫລດ ສະແດງຄວາມຄືບໜ້າ ແລະ ຜົນຮັບຂອງການ sync ຟາຍ @@ -408,38 +633,57 @@ ແຈ້ງເຕືອນທົ່ວໄປ ຄວາມຄືບໜ້າຂອງເເອັບຫຼິ້ນເພງ ແອັບສື່ມວນຊົນ + Shows progress of offline file operations + Offline operations ສະແດງການແຈ້ງເຕືອນໂດຍເຊີເວີ: ຄໍາເຫັນ, ການນແບ່ງປັນໄລຍະໄກສອກ, ການປະກາດໂດຍ admin ແລະ ອື່ນໆ. ການແຈ້ງເຕືອນ ສະແດງຄວາມຄືບຫນ້າໃນການອັບໂຫຼດ ອັບໂຫຼດ icon ການແຈ້ງເຕືອນ + Unread notifications exist ບໍ່ມີການແຈ້ງເຕືອນ ກະລຸນາກວດກາຄືນໃນເວລາຕໍ່ມາ. + Pending operation + Pending Remove Operation ບໍ່ມີການເຊື່ອມຕໍ່ອິນເຕີເນັດ + Even without an internet connection, you can organize your folders, create files. Once you\'re back online, your pending actions will automatically sync. + You\'re offline, but work continues + File does not exists, yet. Please upload the file first. + Could not create %s. A file with the same name exists on the server. + Could not create %s. A folder with the same name exists on the server. + The offline operation cannot be completed. %s + Offline Operations + Cancelled deletion of %s. The file has been modified on the server. + Cancelled rename of %s. A file with the same name exists on the server. + Starting offline operations 1 ຊົ່ວໂມງ ອອນລາຍ ສະຖານະພາບອອນລາຍ + Open in %1$s ການເຂົ້າຟາຍເຊີເວີສິ້ນສຸດ, ກະລຸນາຍົກລະດັບ! ເມນູເພີ່ມເຕີມ ປ້ອນລະຫັດຂອງທ່ານ ລະຫັດຜ່ານຈະຖືກຮ້ອງຂໍເມື່ອແອັບພລິເຄຊັນເລີ່ມຕົ້ນ ກະລຸນາປ້ອນລະຫັດຂອງທ່ານ + The passcode will be requested every time the app is opened or reopened after 5 seconds. ລະຫັດຜ່ານບໍ່ຄືກັນ ກະລຸນາປ້ອນລະຫັດຂອງທ່ານອີກຄັ້ງ ລຶບລະຫັດຜ່ານຂອງທ່ານ ລຶບລະຫັດຜ່ານແລ້ວ ລະຫັດຜ່ານທີ່ເກັບໄວ້ ລະຫັດຜ່ານບໍ່ຖືກຕ້ອງ + Unable to open password-protected PDF. Please use an external PDF viewer. + Tap on a page to zoom in ອະນຸຍາດ ປະຕິເສດ ການອະນຸຍາດເພີ່ມເຕີມທີ່ຈໍາເປັນເພື່ອອັບໂຫຼດ ແລະ ດາວໂຫລດຟາຍ + Pick contact to share with ບໍ່ພົບແອັບພລິເຄຊັນເພື່ອຕັ້ງຮູບດ້ວຍ - 389 KB - 12:23:45 - ຕົວສຳຮອງ - 2012/05/18 12:23 PM + Pin to home screen + ເປີດ %1$s ຢຸດ ສະລັບ + Please select a server… ການກວດກາການປະຢັດພະລັງງານອາດຈະສົ່ງຜົນໃນອັບໂຫຼດຟາຍເມື່ອແບັດເຕີຣີຕ່ໍາ! ລຶບແລ້ວ ເກັບໄວ້ໃນໂຟນເດີເດິມ @@ -451,14 +695,23 @@ ປ່ຽນຊື່ສະບັບໃຫມ່ ຈະເຮັດແນວໃດຖ້າຟາຍມີຢູ່ແລ້ວ? ເພີ່ມບັນຊີ + Sync calendar and contacts ທັງ F-Droid ແລະ Google Play ບໍ່ໄດ້ຖືກຕິດຕັ້ງ + Set up DAVx⁵ (formerly known as DAVdroid) (v1.3.0+) for current account + Calendar and contacts sync set up ກ່ຽວກັບ ລາຍລະອຽດ Dev + ຟາຍ ທົ່ວໄປ ເພີ່ມເຕີມ Sync + Daily backup of your calendar and contacts ສໍາຮອງປະຈໍາວັນຂອງການຕິດຕໍ່ + Data storage location + Manage data storage location + Unexpected error while setting up DAVx⁵ (formerly known as DAVdroid) + End-to-end encryption is set up! E2E mnemonic ເພື່ອສະແດງ mnemonic ກະລຸນາເປິດຂໍ້ມູນປະຈຳຕົວຂອງອຸປະກອນ. ສະແດງການແຈ້ງເຕືຶອນສື່ ມວນ ຊົນ @@ -468,7 +721,12 @@ ປະທັບ ຟາຍເດີມຈະ.... ຟາຍເດີມຈະ.... + Exclude hidden files and folders + Exclude hidden + Store in subfolders based on date ໃຊ້ໂຟນເດີຫຍ້ອຍ + Subfolder options + Add end-to-end encryption to this client ໃບອະນຸຍາດ ລະຫັດຜ່ານApp ການຢັ້ງຢືນອຸປະກອນ @@ -478,41 +736,70 @@ ການຢັ້ງຢືນອຸປະກອນ ລະຫັດຜ່ານ ຈັດການບັນຊີ + Recommend to a friend + Remove encryption locally + Set up end-to-end encryption + Show app switcher + Nextcloud app suggestions in navigation heading ສະແດງຟາຍທີ່ເຊື່ອງໄວ້ ຮັບລະຫັດແຫຼ່ງຂໍ້ມູນ ຈັດການໂຟນເດີສໍາລັບການອັບໂຫລດອັດຕະໂນມັດ ໂຟນເດີ ໂຟນໄລຍະໄກ ຫົວຂໍ້ + Interval + Manage internal folders for two way sync + Enable two way sync ມືດ ແຈ້ງ ຕາມລະບົບ ເບິງຮູບພາບ + Downloading image to start the edit screen, please wait… ບໍ່ມີຟາຍໃນຊ່ອງເກັບທີ່ຈະເບິ່ງລ່ວງຫນ້າ ບໍ່ສາມາດສະແດງຮູບພາບໄດ້ + File is not downloaded + File is currently locked by another user or process and therefore not deletable. Please try again later. ຂໍໂທດ ສ່ວນຕົວ ປິດການແຈ້ງເຕືອນ ເນື່ອງຈາກການເພິ່ງພາອາໄສການບໍລິການ Google Play ທີ່ເປັນເຈົ້າຂອງ. ບໍ່ມີການແຈ້ງ ເຕືອນ ເນື່ອງ ຈາກພາກ ການເຂົ້າລະບົບ ສະໄຫມ. ກະລຸນາພິຈາລະນາການເພີ່ມບັນຊີຂອງທ່ານຄືນໃຫມ່. ການແຈ້ງເຕືອນຊຸກຍູ້ໃນປະຈຸບັນຍັງບໍ່ມີ. + QR code could not be read! + Folder cannot be found, sync operation is cancelled + Unable to find file to upload ລອງໃສ່ອຸປະກອນຂອງທ່ານ!%1$s ຂ້າພະເຈົ້າຢາກເຊີນທ່ານໃຊ້ໃນອຸປະກອນຂອງທ່ານ%1$s.nDownload ທີ່ນີ້:%2$s %1$sຫລື%2$s + Recommended files + Refresh content ໂຫຼດຄຶນ (ໄລຍະໄກ) ຊອກຫາຟາຍບໍ່ສຳເລັດ + You can remove end-to-end encryption locally on this client + You can remove end-to-end encryption locally on this client. The encrypted files will remain on server, but will not be synced to this computer any longer. ການລຶບບໍ່ສຳເລັດ + Remove local account + Remove account from device and delete all local files ບໍ່ສາມາດລຶບການແຈ້ງການແຈ້ງເຕືອນໄດ້. ຍ້າຍອອກ ລືບ ໃສ່ຊື່ໃໝ່ ບໍ່ສາມາດປ່ຽນຊື່ສໍາເນົາໃນຊ່ອງເກັບຂໍ້ມູນໄດ້, ລອງໃຊ້ຊື່ອື່ນ ປ່ຽນຊື່ບໍ່ໄດ້ ມີຊື່ແລ້ວ + Request account deletion + Request deletion + Request permanent deletion of account by service provider + Policy or permissions prevent resharing ກູ້ຟາຍຄຶນ + Restore backup ກຸ້ຟາຍທີ່ລືບ + Restore + Restore selected ການເກັບກໍາຂໍ້ມູນຟາຍ... + Retry ບໍ່ສາມາດໂຫຼດເອກະສານໄດ້! ເຂົ້າລະບົບໂດຍຜ່ານການສະເເກນ QR code + Scan page ການປົກປ້ອງຂໍ້ມູນຂອງທ່ານ ປະສິດພາບການເຮັດວຽກດ້ວຍໂຕມັນເອງ ເອິ້ນເບິ່ງ ແລະ ແບ່ງປັນ @@ -522,19 +809,39 @@ ບັນຊີທັງໝົດ ໃນບ່ອນດຽວ ອັບໂຫຼດອັດຕະໂນມັັດ + for your photos and videos + Calendar and contacts + Sync with DAVx⁵ + Error getting search results + Secure sharing is not set up for this user + Secure share… ເລືອກທັງໝົດ + Set media folder ເລືອກຕົວຢ່າງ1 ອັນ ເລືອກຕົວຢ່າງ ສົ່ງ + Send share ສົ່ງປຸ່ມ icon + Could not load content + The device is likely not connected to the internet ຕັ້ງຄ່າເປັນ + Unable to set download limit. Please check capabilities. + Set message + Set note ສະຖານະພາບອອນລາຍ ໃຊ້ຮູບເປັນ + During setup of end-to-end encryption, you will receive a random 12 word mnemonic, which you will need to open your files on other devices. This will only be stored on this device, and can be shown again in this screen. Please note it down in a secure place! ແບ່ງປັນ + ອະນຸຍາດໃຫ້ດາວໂຫຼດ ແລະ ຊິ້ງຂໍ້ມູນ + We couldn’t update the share. Please add a note and try again. + Share and copy link ສ້າງ + ສິດອະນຸຍາດແບບກຳນົດເອງ ລຶບ ການແບ່ງປັນ + Set download limit ແກ້ໄຂ + %1$s ແບ່ງປັນ%1$s (ກຸ່ມ)%1$s ແບ່ງປັນລິງພາຍໃນ @@ -551,10 +858,20 @@ ແບ່ງປັນ(%1$s) ກໍານົດວັນໝົດອາຍຸ ກຳນົດລະຫັດຜ່ານ + Resharing is not allowed during secure file drop + Please select at least one sharing option before continuing. ແກ້ໄຂໄດ້ + ຄຳຂໍໄຟລ໌ + Secure file drop + ເບິ່ງເທົ່ານັ້ນ + Share permissions ແບ່ງປັນ + ອ່ານ %1$s(ໄລຍະໄກ) %1$s(ສົນທະນາ) + Name, Federated Cloud ID or email address… + Add users and teams + Send new email ລະບຸຜູ້ຮັບ ການຕັ້ງຄ່າ ເຊື່ອງການດາວໂຫຼດ @@ -568,9 +885,17 @@ ແບ່ງປັນຜ່ານລິງ ແບ່ງປັນໂດຍທ່ານ %1$s ການເພີ່ມການແບ່ງປັນບໍ່ສໍາເລັດ + Adding share failed. This file or folder has already been shared with this person or group. + ສະແດງທັງໝົດ + Show photos + ສະແດງໜ້ອຍລົງ + Show videos + Please manually check terms of service! ລົງທະບຽນກັບຜູ້ໃຫ້ບໍລິການ ອະນຸຍາດ%1$sໃຫ້ເຂົ້າບັນຊີ Nextcloud ຂອງທ່ານ%2$s? ຈັດລຽງໂດຍ + Sort favorites first + Sort folders before files ເຊື່ຶອງ ລາຍລະອຽດ ການລະບຸຕົວຕົນຂອງເຊີເວີ ບໍ່ສາມາດຢັ້ງຢືນໄດ້ @@ -602,21 +927,34 @@ ເລີ່ມຕົ້ນ ເອກະສານ ດາວໂຫລດ + External storage ການເກັບກໍາຂໍ້ມູນພາຍໃນ ລະຄອນໂທລະທັດ ເພງ + Full access + Media read-only ຮູບພາບ + The self-hosted productivity platform that keeps you in control.\n\nFeatures:\n* Easy, modern interface, suited to the theme of your server\n* Upload files to your Nextcloud server\n* Share them with others\n* Keep your favorite files and folders synced\n* Search across all folders on your server\n* Auto Upload for photos and videos taken by your device\n* Keep up to date with notifications\n* Multi-account support\n* Secure access to your data with fingerprint or PIN\n* Integration with DAVx⁵ (formerly known as DAVdroid) for easy setup of calendar and contacts synchronization\n\nPlease report all issues at https://github.com/nextcloud/android/issues and discuss this app at https://help.nextcloud.com/c/clients/android\n\nNew to Nextcloud? Nextcloud is a private file sync and share and communication server. It is libre software, and you can host it yourself or pay a company to do it for you. That way, you are in control of your photos, your calendar and contact data, your documents and everything else.\n\nCheck out Nextcloud at https://nextcloud.com platform ການເຮັດວຽກ ເປັນ host ດ້ວຍຕົນເອງ ແມ່ນຊ່ວຍໃຫ້ທ່ານຄວບຄຸມໄດ້ .\n ເປັນເວີຊັ້ນການພັດທະນາຢ່າງເປັນທາງການ ຊຶ່ງມີຕົວຢ່າງການເຮັດວຽກທີ່ຍັງບໍ່ໄດ້ທົດລອງອາດເຮັດໃຫ້ເກີດຄວາມບໍ່ສະຖຽນ ແລະ ການສູນເສຍຂອງຂໍ້ມູນ ແອັບນີ້ສຳລັບຜູ້ທີ່ ຍິນດີຈະທົດລອງ ແລະ ລາຍງານຂໍ້ບົກຜ່ອງ. ຫ້າມໃຊ້ສຳລັບວຽກຕົວຈິງຂອງທ່ານ!\n\n ໃຫ້ຜູ້ພັດທະນາ ແລະ ເວີຊັ້ນຢ່າງເປັນທາງການພ້ອມໃຊ້ງານໃນ F-droid ແລະ ສາມາດຕິດຕັ້ງໄດ້ໃນເວລາດຽວກັນ. ປະສິດພາບການເຮັດວຽກຂອງ ຟາດຟອມ ທີ່ເຮັດໜ້າທີ່ດ້ວຍໂຕມັນເອງ ແລະ ທ່ານຄຸ້ມຄອງໄດ້ ປະສິດພາບການເຮັດວຽກຂອງ ຟາດຟອມ ທີ່ເຮັດໜ້າທີ່ດ້ວຍໂຕມັນເອງ ແລະ ທ່ານຄຸ້ມຄອງໄດ້ (ເວີຊັ້ນຕົວຢ່າງ) ກະຈາຍດ້ວຍ ບໍ່ສາມາດກະຈາຍພາຍໃນໄດ້ ກະລຸນາດາວໂຫລດສື່ມວນຊົນແທນ ຫຼື ໃຊ້ແອັບພາຍນອກ + Year/Month/Day + Year/Month + ປີ \"%1$s\"ໄດ້ຖືກແບ່ງໂດຍທ່ານ %1$sແບ່ງປັນ\"%2$s\"ດ້ວຍທ່ານ + Photos only + Photos and videos + Videos only + Suggest Sync + Sync anyway ພົບຂໍ້ຜິດພາດ ໂຟນເດີນີ້%1$sບໍ່ມີອີກແລ້ວ + Sync duplication ບໍ່ສາມາດ sync%1$sໄດ້ ລະຫັດບໍ່ຖືກຕ້ອງສໍາລັບ%1$s ຟາຍທີ່ ເກັບຮັກສາໄວ້ ໃນ sync ບໍ່ສຳເລັດ @@ -633,6 +971,7 @@ ພື້ນທີ່ຈັດເກັບບໍ່ພຽງພໍ ສະຖານະປຸ່ມ Sync ຟາຍ + Sync warning button ປຸ່ມການຕັ້ງຄ່າ ກຳນົດຄ່າໂຟນເດີ ການອັບໂຫຼດທັນທີໄດ້ຮັບການປັບປຸງ. ຕັ້ງຄ່າການອັບໂຫຼດອັດຕະໂນມັດຄຶນຈາກ main menu.\n\nEnjoy ການອັບໂຫຼດອັດຕະໂນມັດ. @@ -641,22 +980,39 @@ ພິມ Synced ປ້າຍກຳກັບ + ເງື່ອນໄຂການໃຫ້ບໍລິການ + I agree to the above ToS ທົດສອບການເຊື່ອມຕໍ່ເຊີເວີ 30 ນາທີ ທິດນີ້ ຮຸບຂະໜາດຫຍໍ້ ຮຸບຂະໜາດຫຍໍ້ຂອງຟາຍທີ່ມີຢູ່ ຮຸບຂະໜາດຫຍໍ້ຂອງຟາຍໃໝ່ + Loading is taking longer than expected ມື້ນີ້ ຟາຍທີ່ ຖືກ ລຶບ ບໍ່ມີຟາຍທີ່ຖືກລຶບອອກ ທ່ານຈະສາມາດກຸ້ຟາຍທີ່ຖືກລຶບອອກຈາກທີ່ນີ້. ຟາຍ%1$sບໍ່ສາມາດລຶບໄດ້! ຟາຍ %1$sບໍ່ສາມາດກູ້ຄືນໄດ້! + Delete permanently + Loading trash bin failed! ຟາຍບໍ່ສາມາດລຶບໄດ້ຖາວອນ! + Disable for all folders + To set up a two way sync folder, please enable it in the details tab of the folder in question. + Two way sync not set up + Internal two way sync + Unexpected error occurred + Event not found, you can always sync to update. Redirecting to web… + Contact not found, you can always sync to update. Redirecting to web… + Permissions are required to open search result otherwise it will redirected to web… + In this folder ບໍ່ຮູ້ + Unlock file ຄວາມຄິດເຫັນທີ່ບໍ່ອ່ານ ຍົກເລີິກການຕັ້ງຄ່າເຂົ້າລະຫັດຜ່ານ + Remove from favorites + Remove folder from internal two way sync ເກີດຂໍ້ຜິດພາດໃນຂະນະທີ່ພະຍາຍາມຍົກເລີກຟາຍແບ້ງປັນ ຫຼື ໂຟນເດີນີ້. ບໍ່ສາມາດແບ່ງປັນໄດ້. ກະລຸນາກວດເບິ່ງວ່າມີຟາຍຢູ່ ຫຼື ບໍ່. ເພື່ອຍົກເລີກການແບ່ງປັນຟາຍນີ້ @@ -666,11 +1022,20 @@ ບໍ່ສາມາດປັບປຸງໄດ້. ກະລຸນາກວດເບິ່ງວ່າຟາຍມີຢູ່ຫຼືບໍ່. ເພື່ອປັບປຸງການແບ່ງປັນນີ້ ການປັບປຸງການແບ່ງປັນບໍ່ສໍາເລັດ + Clear cancelled uploads + Resume cancelled uploads ການອັບໂຫຼດບໍ່ສຳເລັດ + Retry failed uploads + Some files no longer exist. These uploads cannot be resumed. + Pause all uploads + Resume all uploads ບໍ່ສາມາດສ້າງຟາຍໄດ້ ອັບໂຫຼດຈາກ... ອັບໂຫລດເນື້ອຫາຈາກແອັບອື່ນໆ + Photo + Do you want to take a photo or video? ອັລໂຫຼດຈາກກ້ອງຖ່າຍຮູບ + ວິດີໂອ ຊື່ຟາຍ ຊະນິດຟາຍ ຟາຍໜ້າປົກ Google Maps(%s) @@ -678,16 +1043,27 @@ ຟາຍຂໍ້ຄວາມສ່ວນຫຍ່ອຍ(.txt) ຊື່ຟາຍ ແລະ ຊະນິດຟາຍ ເພື່ອອັບໂຫຼດຟາຍ ອັບໂຫຼດຟາຍ + All uploads are paused ອັບໂຫຼດປຸ່ມ ໃນລາຍການການອັບໂຫຼດ + Cancel upload ລຶບ ບໍ່ມີການອັບໂຫຼດ ອັບໂຫລດເນື້ອຫາບາງຢ່າງ ຫຼື ເປີດການອັບໂຫລດອັດຕະໂນມັດ. + Toggle expansion of header ແກ້ໄຂຂໍ້ຜິດພາດ ຊ່ອງເກັບຂໍ້ມູນເຕັມ ບໍ່ສາມາດສໍາເນົາຟາຍເຂົ້າໃນການເກັບກໍາຂໍ້ມູນໃນເກັບຂໍ້ມູນໄດ້ ການລ໋ອກໂຟນເດີບໍ່ສຳເລັດ + Upload was cancelled by user + Allow all file access + App permissions + Your files cannot be uploaded without access to local storage. Tap to grant permission. + Upload Stopped – Storage Permission Required + %1$d / %2$d - %3$s ການເຂົ້າລະຫັດສາມາດເຂົ້າໄດ້ດ້ວຍ>= Android 5.0 ພື້ນທີ່ບໍ່ພຽງພໍ ໃນການສໍາເນົາຟາຍທີ່ເລືອກເຂົ້າໄປໃນໂຟນເດີ %1$s. ທ່ານຕ້ອງຍ້າຍໄປຢູ່ຫັ້ນແທນບໍ? + Storage quota exceeded + Scan document from camera Syncຜິດພາດ, ກະລຸນາແກ້ໄຂດ້ວຍມື ຜິດພາດທີ່ບໍ່ຮູ້ຈັກ ເລືອກ @@ -696,8 +1072,12 @@ ບໍ່ອະນຸຍາດໃຫ້ອ່ານຟາຍທີ່ໄດ້ຮັບ%1$s ບໍ່ສາມາດສໍາເນົາຟາຍ ໄປຫາໂຟນເດີຊົ່ວຄາວໄດ້.ລອງສົ່ງອີກຄັ້ງ. ບໍ່ພົບຟາຍທີ່ຖືກຄັດເລືອກສໍາລັບການອັບໂຫຼດ. ກະລຸນາກວດເບິ່ງວ່າຟາຍມີຢູ່ ຫຼື ບໍ່ + This file cannot be uploaded ບໍ່ມີຟາຍທີ່ຈະອັບໂຫຼດ + File not found. Are you sure that this file exists or has a previous conflict not been resolved? + We couldnt locate the file on server. Another user may have deleted the file ຊື່ໂຟນເດີ + Retry to upload failed local files ເລືອກໂຟນເດີອັບໂຫລດ ບໍ່ສາມາດອັບໂຫຼດໄດ້%1$s ອັບໂຫຼດບໍ່ສຳເລັດ, ເຂົ້າສູ່ລະບົບອີກຄັ້ງ @@ -734,7 +1114,10 @@ ໃບຢັ້ງຢືນເຊີເວີ ບໍ່ໜ້າເຊື່ອຖື ກຳລັງນໍາເອົາເວີຊັ້ນເຊີເວີມາ ແອັບ ໄດ້ຖືກຍົກເລີກ + Skipped + A file with the same name already exists. ສຳເລັດ + Same file found on remote, skipping upload ຂໍ້ຜິດພາດ ກວດພົບໄວຣັສ. ອັບໂຫຼດບໍ່ສໍາເລັດ! ການລໍຖ້າທີ່ຈະອອກຈາກການປະຢັດພະລັງງານ @@ -751,33 +1134,97 @@ ເພີ່ມຊື່, ຮູບພາບ ແລະ ລາຍລະອຽດການຕິດຕໍ່ ໃນຫນ້າປົກຂອງທ່ານ. ຊື່ຜູ້ໃຊ້ ດາວໂຫລດ + Video overlay icon ລໍຖ້າໜ້ອຍໜຶ່ງ... ການກວດສອບຂໍ້ມູນສວນຕົວ ການສໍາເນົາຟາຍຈາກຖານເກັບຂໍ້ສ່ວນຕົວ + Changing the extension might cause this file to open in a different application ຮູບພາບໃຫມ່ ຂ້າມໄປ ໃຫມ່ໃນ%1$s + What is your status? + Widgets are only available in %1$s 25 or later when the Dashboard app is enabled ບໍ່ມີ ກຳລັງອັບໂຫຼດຟາຍນີ້ ສົ່ງອີເມວ ບໍ່ມີໂຟນເດີເກັບຂໍ້ມູນ! ນີ້ອາດຈະເປັນຍ້ອນການກູ້ຄືນການສໍາຮອງໃນອຸປະກອນອື່ນ.ການກັບຄືນຄ່າເດີມບໍ່ສຳເລັດ. ກະລຸນາກວດເບິ່ງການຕັ້ງຄ່າເພື່ອປັບຟາຍການເກັບຂໍ້ມູນ. + + %d hours + + + %d minutes + + + %d second ago + + + %d minutes ago + + + %d hours ago + ບໍ່ສາມາດ sync%1$d ຟາຍ (ຜິດພາດ:%2$d ) ບໍ່ສາມາດສໍາເນົາຟາຍ %1$dຈາກໂຟນເດີ ເຂົ້າໄປໃນ%2$s + + Wrote %1$d events to %2$s + + + Created %1$d fresh UIDs + + + Processed %d entries. + + + Found %d duplicate entries. + + + Exported %d files + + + Failed to export %d files + + + Exported %d files, skipped rest due to error + + + %d files will be exported. See notification for details. + + + You can upload up to %d files at once. + ໂຟນເດີ%1$d ຟາຍ%1$d + + %1$d items + ສະແດງໂຟນເດີທີ່ເຊື່ອງໄວ້%1$d ເລືອກ%d - + + Delayed %d seconds due to too many wrong attempts + + + Delayed %d minutes due to too many wrong attempts + + + Delayed %d minutes + + + %d seconds + + + %1$d downloads remaining + + diff --git a/app/src/main/res/values-lt-rLT/strings.xml b/app/src/main/res/values-lt-rLT/strings.xml index c7b29dc..7ccbd41 100644 --- a/app/src/main/res/values-lt-rLT/strings.xml +++ b/app/src/main/res/values-lt-rLT/strings.xml @@ -468,7 +468,6 @@ Išvalyti duomenis Nustatymai, duomenų bazė ir serverio sertifikatai iš %1$s duomenų bus ištrinti negrįžtamai. \n\nAtsisiųsti failai bus išsaugoti.\n\nŠis procesas gali užtrukti. Tvarkyti vietą - Pasiektas įkeliamų failų limitas. Kelkite mažiau nei 500 failų. Medijos failas negali būti transliuojamas Nepavyko perskaityti medijos failo. @@ -499,6 +498,7 @@ Nepavyko užbaigti operacijos, Serveris nepasiekiamas. Naujas komentaras… Aptiktas naujas %1$s medijos failas. + Naujas įspėjimas Buvo sukurta nauja versija Nėra programėlės, skirtos apdoroti nuorodas Nėra jokių kalendorių @@ -544,11 +544,6 @@ Papildomos teisės reikalingos įkelti šiuos atsisiųstus failus. Nerasta programa, su kuria būtų galima nustatyti nuotrauką Atverti %1$s - 389 KB - 12:23:45 - Paskiausiai taisyti - Rezervas - 2012/05/18 12:23 PM stabdyti perjungti Pasirinkite serverį… @@ -568,6 +563,7 @@ Apie Informacija Dev + Failai Bendra Daugiau Sinchronizuoti @@ -686,6 +682,7 @@ Nustatyti galiojimo pabaigos datą Nustatyti slaptažodį Galima redaguoti + Failų įkėlimui Saugus failų įkėlimas Tik peržiūrėti Bendrinti @@ -849,6 +846,7 @@ Įrašykite failo pavadinimą ir tipą įkėlimui Įkelti failus Įkelti elemento veiksmo mygtuką + Atšaukti įkėlimą Ištrinti Atnaujinimų nerasta Įkelkite failus arba sinchronizuokite su savo įrenginiais diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index b3485f2..0580445 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -63,6 +63,7 @@ %1$s neatbalsta vairākus kontus. Neizdevās izveidot savienojumu Atcelt pieteikšanos + Nav pieejams neviens pārlūks, lai atvērtu šo saiti. Lūgums pabeigt pieteikšanos savā pārlūkā glabāts oriģinālajā mapē, jo tā ir tikai lasāma Augšupielādēt tikai neirobežotā Wi-Fi @@ -106,6 +107,7 @@ Ielādē … Nākamā + Tagad Labi Gaida Izdzēst @@ -397,11 +399,6 @@ Nav atrasta neviena lietotne, ar ko iestatīt attēlu Piespraust sākuma ekrānam Atvērt %1$s - 389 KB - 12:23:45 - Nesen labots - Šis ir viettura teksts - 2012/05/18 12:23 PM apturēt pārslēgt Lūgums atlasīt serveri… @@ -422,6 +419,7 @@ Kalendāra un kontaktpersonu sinhronizēšanas iestatīšana Par Informācija + Datnes Vispārīgi Vairāk Sinhronizēt @@ -526,6 +524,7 @@ Reģistrēties ar pakalpojumu sniedzēju Ļaut %1$s piekļūt Tavam Nextcloud kontam %2$s? Kārtot pēc + Kārtot mapes pirms datnēm Paslēpt Sīkāka informācija Servera identitāti nevarēja pārbaudīt diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml index e95d84e..2a24194 100644 --- a/app/src/main/res/values-mk/strings.xml +++ b/app/src/main/res/values-mk/strings.xml @@ -32,6 +32,7 @@ Прикажува еден виџет од контролната табла Барај во %s Избриши задача + Асистент Не е пронајдена поврзана сметка! Неуспешен пристап: %1$s Сметката сеуште не е додадена на овој уред @@ -76,6 +77,7 @@ Оневозможи Вашиот уред може да има овозможено оптимизација на батеријата. Автоматското прикачување работи само ако соодветно ја исклучите оваа апликација од оптимизацијата на батеријата. Оптимизација на батеријата + Асистент Омилени Сите датотеки Медија @@ -110,6 +112,7 @@ Се вчитува… Следна Не + Сега Во ред Чекање Избриши @@ -207,6 +210,7 @@ Background image of drawer header Активности Сите датотеки + Асистент Омилени Медија Дома @@ -222,6 +226,7 @@ искористено %1$s од %2$s искористено %1$s Автоматско прикачување + Асистент Постави како енкриптирано Постави енкрипција Дешифрирање… @@ -364,6 +369,7 @@ Неправилна URL Невидливо Линк + Уредување Listed layout Вчитај повеќе резултати Нема датотеки во оваа папка. @@ -459,11 +465,6 @@ Потебни се дополнителни привилегии за прикачување и превземање на датотеки. Не е пронајдена апликација за поставување на слики Отвори %1$s - 389 KB - 12:23:45 - Неодамна изменета - Ова е резервирано место - 2012/05/18 12:23 PM стоп вклучи Оневозможување на проверката за заштеда на енергијата може да овозможи прикачување на датотеки и кога батеријата е празна @@ -481,6 +482,7 @@ За Детали: Dev + Датотеки Општо Повеќе Синхронизирај @@ -604,6 +606,8 @@ Пријавете се преку провајдер Дозволи му на %1$s да пристапи на твојата Nextcloud сметка %2$s? Сортирај по + Прво омилените + Подреди ги папките пред датотеките Сокриј Детали Идентитетот на серверот не може да се потврди diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index da5b6fe..bbbfabc 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -515,7 +515,6 @@ Nullstill data Innstillinger, database og serversertifikater fra %1$s\'s filer vil bli slettet. \n\nNedlastede filer blir urørt.\n\nDette kan ta noe tid. Håndter plass - Du har nådd den maksimale grensen for filopplasting. Vennligst last opp færre enn 500 filer om gangen. Mediafilen kan ikke strømmes Mediafilen kunne ikke leses Mediafilen er ikke riktig kodet @@ -600,12 +599,6 @@ Flere tillatelser trengs for å laste opp og ned filer. Fant ikke noe app å sette bilde med. Åpne %1$s - .txt - 389 KB - 12:23:45 - Nylig redigert - Dette er en plassholder - 18.05.2012 12:23 stopp av/på Vennligst velg en tjener… @@ -626,6 +619,7 @@ Om Detaljer Utvikling + Filer Generelt Mer Synkroniser @@ -798,6 +792,8 @@ Logg på med tilbyder Tillat %1$s adgang til din Nextcloud konto %2$s? Sorter etter + Sorter favoritter først + Sorter mapper før filer Skjul Detaljer Serverens identitet kunne ikke bekreftes diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 59fdd1b..1b585db 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -556,7 +556,6 @@ Gegevens verwijderen Instellingen, database en servercertificaten van de data van %1$s zullen permanent worden verwijderd. \n\nGedownloade bestanden blijven onaangeroerd.\n\nDit proces kan even duren. Beheer ruimte - Je hebt het maximum van 500 bestanden per upload overschreden. Mediabestand kan niet worden gestreamd Kon het mediabestand niet lezen Mediabestand niet goed gecodeerd @@ -658,12 +657,6 @@ Geen app gevonden om afbeelding in te stellen Vastzetten op startscherm Open %1$s - .txt - 389 KB - 12:23:45 - Recent bewerkt - Dit is een plaatshouder - 2012/05/18 12:23 stop omschakelen Selecteer een server... @@ -685,6 +678,7 @@ Over Details Dev + Bestanden Algemeen Meer Synchroniseren @@ -874,6 +868,8 @@ Aanmelden bij provider Toestaan dat%1$s je Nextcloud account %2$sbenadert? Sorteer op + Sorteer eerst favorieten + Sorteer mappen voor bestanden Verbergen Details De identiteit van de server kon niet geverifieerd worden diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index efd283b..1094b12 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -93,12 +93,19 @@ %1$s nie wspiera wielu kont Nie udało się ustanowić połączenia Anuluj logowanie + Proszę wprowadzić poprawny adres serwera. + Nie można pobrać danych logowania. Spróbuj ponownie. Wystąpił problem podczas przetwarzania żądania logowania. Spróbuj ponownie później. + Brak dostępnej przeglądarki do otwarcia tego linku. Dokończ proces logowania w przeglądarce + Automatyczna aktualizacja jest wstrzymana, ponieważ tryb oszczędzania baterii jest włączony. zachowany w oryginalnym katalogu, jak jest tylko do odczytu + Niski poziom baterii, aktualizacja może potrwać dłużej. Wysyłaj tylko przez Wi-Fi /AutoUpload Ten katalog jest już uwzględniony w synchronizacji katalogu nadrzędnego, co może powodować duplikowanie wysyłanych plików + Oczekiwanie na Wi-Fi, aby zacząć aktualizowanie + Przesyłanie plików z %s do %s Konfiguruj Utwórz nową niestandardową konfigurację katalogu Ustaw własny katalog @@ -202,6 +209,7 @@ Nie udało się rozpocząć importu. Spróbuj ponownie Nie znaleziono żadnych plików Nie odnaleziono Twojej ostatniej kopii zapasowej! + Sprawdzanie zmian treści Skopiowano do schowka Wystąpił błąd podczas próby kopiowania tego pliku lub katalogu Nie można skopiować katalogu do jednego z jego podkatalogów @@ -471,6 +479,11 @@ Katalog już istnieje Ten katalog najlepiej oglądać w formacie %1$s. Utwórz + %1$d z %2$d · %3$s + Wystąpił błąd podczas synchronizacji katalogu %s + Brak miejsca na dysku, synchronizacja przewana + %s pomyślnie zsynchronizowany + Synchronizacja… Brak katalogów Nazwa katalogu nie może być pusta Wybierz @@ -558,7 +571,6 @@ Wyczyść dane Ustawienia, bazy danych i certyfikaty serwera %1$s zostaną trwale usunięte.\n\nPobrane pliki pozostaną na swoich miejscach.\n\nTen proces może trochę potrwać. Zarządzaj przestrzenią - Osiągnięto maksymalny limit upload. Proszę uploadować mniej niż 500 plików w jednym czasie Plik multimedialny nie może być przesyłany strumieniowo Nie można odczytać pliku Nieprawidłowe kodowanie pliku multimedialnego @@ -607,6 +619,8 @@ Wykonanie akcji nie powiodło się. Pokaż powiadomienia o interakcji z wynikami operacji wykonywanych w tle Operacje w tle + Wykryj zmiany lokalne + Monitor zawartości Pokazuje postęp pobierania Pobrane Pokazuje postęp synchronizacji plików i jej wynik @@ -662,13 +676,6 @@ Nie znaleziono aplikacji do ustawienia obrazu Przypnij do ekranu głównego Otwórz %1$s - .txt - 389 KB - symbol zastępczy - 12:23:45 - Ostatnio edytowane - Tekst zastępczy - 2012/05/18 12:23 PM zatrzymaj przełącz Wybierz serwer... @@ -690,6 +697,7 @@ O aplikacji Szczegóły Deweloperskie + Pliki Ogólne Więcej Synchronizuj @@ -881,6 +889,8 @@ Zarejestruj się u usługodawcy Zezwolić %1$s na dostęp do Twojego konta Nextcloud %2$s? Sortuj według + Najpierw sortuj ulubione + Sortuj katalogi przed plikami Ukryj Szczegóły Nie można zweryfikować identyfikacji serwera @@ -991,6 +1001,7 @@ Nie znaleziono zdarzenia, zawsze możesz zsynchronizować, aby zaktualizować. Przekierowanie do strony internetowej… Nie znaleziono kontaktu, zawsze możesz zsynchronizować, aby zaktualizować. Przekierowanie do strony internetowej… Aby otworzyć wyniki wyszukiwania, wymagane są uprawnienia, w przeciwnym razie nastąpi przekierowanie do strony internetowej… + W tym katalogu Nieznana Odblokuj plik Nieprzeczytane komentarze diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ad4280f..9a64cbf 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -44,6 +44,7 @@ Mostra um widget do painel Pesquisar em %s Aparecer off-line + Este conteúdo foi gerado por IA e pode conter erros. Adicionar nova tarefa Criar uma nova tarefa no canto inferior direito Digite algum texto @@ -55,6 +56,7 @@ Ocorreu um erro ao excluir a tarefa Tarefa excluída com sucesso A lista de tarefas está vazia + A lista de tarefas está vazia. Verifique a configuração do aplicativo assistente. Não foi possível buscar a lista de tarefas. Verifique sua conexão com a Internet. Excluir Tarefa O resultado da tarefa ainda não está pronto. @@ -93,12 +95,19 @@ %1$s não tem suporte a múltiplas contas Não foi possível estabelecer a conexão Cancelar login + Insira um endereço de servidor válido. + Não foi possível obter os detalhes de login. Por favor, tente novamente. Houve um problema ao processar sua solicitação de login. Tente novamente mais tarde. + Não há nenhum navegador disponível para abrir este link. Por favor, conclua o processo de login no seu navegador + O upload automático está pausado porque o Economizador de Bateria está ativado. mantido na pasta original, já que é somente leitura + Bateria fraca, o upload pode demorar mais tempo Enviar só por WiFi não medida /UploadAutomático Esta pasta já está incluída na sincronização da pasta pai, o que pode causar uploads duplicados + Aguardando o Wi-Fi para iniciar o upload + Fazendo upload de arquivos de %s a %s Configurar Criar nova configuração de pasta personalizada Configurar uma pasta personalizada @@ -202,6 +211,7 @@ Falha ao iniciar a importação. Por favor, tente novamente. Nenhum arquivo encontrado Não foi possível encontrar seu último backup! + Detectando alterações no conteúdo Copiado para a área de transferência Ocorreu um erro ao tentar copiar este arquivo ou pasta Não é possível copiar uma pasta para uma das suas próprias pastas subjacentes @@ -337,7 +347,7 @@ Erro ao criar o arquivo a partir do modelo Não é possível obter os destinatários do compartilhamento. Erro ao mostrar as ações do arquivo - Erro ao alterar o status de bloqueio do arquivo + Erro ao alterar o status de trancamento do arquivo Reportar Reportar problema ao rastreador? (requer uma conta GitHub) Erro ao recuperar o arquivo @@ -407,6 +417,8 @@ Nenhum resultado encontrado para sua consulta Inicie sua pesquisa Digite na barra de pesquisa acima para encontrar arquivos, contatos, eventos do calendário e muito mais em sua conta. + Verifique sua conexão com a Internet ou tente novamente mais tarde + Conexão fraca pasta AO VIVO Carregando… @@ -471,6 +483,11 @@ Pasta já existe Esta pasta é melhor visualizada em %1$s. Criar + %1$d de %2$d · %3$s + Ocorreu um erro durante a sincronização da pasta %s + Espaço em disco insuficiente, sincronização cancelada + Pasta %s sincronizada com êxito + Sincronizando… Sem pastas aqui O nome da pasta não pode ficar vazio Escolher @@ -538,9 +555,9 @@ Não há outras pastas. Localizar pasta Expira: %1$s - Bloquear ficheiro - Bloqueado por %1$s - Bloqueado pelo aplicativo %1$s + Trancar arquivo + Trancado por %1$s + Trancado pelo aplicativo %1$s %1$s logs do aplicativo Android Nenhum aplicativo para envio de registros foi encontrado. Instale um cliente de e-mail. Logado como %1$s @@ -558,7 +575,6 @@ Limpar dados Configurações, banco de dados e certificados do servidor de dados de %1$s serão excluídos permanentemente.\n\nArquivos baixados serão mantidos intocados.\n\nEste processo pode levar um tempo. Gerenciar o espaço - Você atingiu o limite máximo de upload de arquivos. Por favor, envie menos de 500 arquivos por vez. O arquivo de mídia não pode ser transmitido Não foi possível ler o arquivo de mídia O arquivo de mídia tem uma codificação incorreta @@ -607,6 +623,8 @@ Erro ao executar a ação. Mostrar notificações para interagir com o resultado de operações em segundo plano Operações em segundo plano + Detecta alterações em arquivos locais + Observador de conteúdo Mostra o progresso de download Downloads Mostra o progresso sincronização de arquivo e resultados @@ -647,6 +665,7 @@ Digite o código de acesso O código de acesso será solicitado toda vez que o aplicativo for iniciado Digite sua senha + A senha será solicitada sempre que o aplicativo for aberto ou reaberto após 5 segundos. Os códigos de acesso não são os mesmos Por favor, digite seu código de acesso novamente Excluir sua frase secreta @@ -662,13 +681,6 @@ Nenhum aplicativo encontrado para definir uma imagem Fixar na tela inicial Abrir %1$s - .txt - 389 KB - espaço reservado - 12:23:45 - Editado recentemente - Este é um espaço reservado - 2012/05/18 12:23 PM para alternar Selecione um servidor… @@ -690,6 +702,7 @@ Sobre Detalhes Desenvolvimento + Arquivos Geral Mais Sincronização @@ -745,7 +758,7 @@ Não há arquivos locais para exibir Não foi possível exibir a imagem O arquivo não foi baixado - O arquivo está atualmente bloqueado por outro usuário ou processo e, portanto, não pode ser excluído. Por favor, tente novamente mais tarde. + O arquivo está atualmente trancado por outro usuário ou processo e, portanto, não pode ser excluído. Por favor, tente novamente mais tarde. Desculpe Privacidade Notificações push desativadas devido a dependências de serviços proprietários do Google Play. @@ -881,6 +894,8 @@ Registre-se com um provedor Permitir %1$s acesso à sua conta %2$s? Ordenar por + Ordenar favoritos primeiro + Ordenar pastas antes de arquivos Ocultar Detalhes A identidade do servidor não pôde ser verificada @@ -991,8 +1006,9 @@ Evento não encontrado, você sempre pode sincronizar para atualizar. Redirecionando para a web… Contato não encontrado, você sempre pode sincronizar para atualizar. Redirecionando para web… São necessárias permissões para abrir o resultado da pesquisa, caso contrário, ele será redirecionado para a web… + Nesta pasta Desconhecido - Desbloquear arquivo + Destrancar arquivo Existem comentários não lidos Definir como não criptografado Remover dos favoritos @@ -1037,7 +1053,7 @@ Resolver conflito O armazenamento local está cheio. O arquivo não pôde ser copiado para o armazenamento local - Falha ao bloquear pasta + Falha ao trancar pasta O upload foi cancelado pelo usuário Permitir acesso a todos os arquivos Permissões do aplicativo @@ -1098,6 +1114,8 @@ Certificado de servidor não confiável Buscando versão do servidor… Aplicativo finalizado + Ignorado + Um arquivo com o mesmo nome já existe. Concluído Mesmo arquivo encontrado no servidor remoto, pulando o upload Erro desconhecido @@ -1206,6 +1224,11 @@ %d arquivos serão exportados. Consulte a notificação para obter detalhes. %d arquivos serão exportados. Consulte a notificação para obter detalhes. + + Você pode enviar apenas %d arquivo por vez. + Você pode enviar até %d de arquivos de uma só vez. + Você pode enviar até %d arquivos de uma só vez. + %1$d pasta %1$d pastas diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index d86d4a2..5a7f8e4 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -582,11 +582,6 @@ Necessário permissões adicionais para enviar e transferir ficheiros. Nenhuma aplicação encontrada para definir a imagem Abrir %1$s - 389 KB - 12:23:45 - Editado recentemente - Isto é uma variável - 2012/05/18 12:23 parar alternar Desabilitar a verificação de poupança de energia pode resultar no envio de ficheiros quando a carga da bateria está baixa! @@ -604,6 +599,7 @@ Sobre Detalhes Desenvolvimento + Ficheiros Geral Mais Sincronizar diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 04d7481..e3b4ab2 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -517,7 +517,6 @@ Elimină datele Setările, bazele de date și certificatele serverului ale %1$s vor fi șterse permanent. \n\nFișierele descărcate vor fi păstrate intacte.\n\nAcest proces poate dura mai mult timp. Administrare spațiu - Ați atins limita maximă de încărcare a fișierelor. Vă rugăm să încărcați mai puțin de 500 de fișiere simultan. Fișierul media nu poate fi difuzat Nu s-a putut citi fișierul media Fișierul media are codare incorectă @@ -599,11 +598,6 @@ Sunt necesare drepturi adiționale pentru a încărca și descărca fișiere. Nu a fost găsită o aplicație pentru a seta imaginea Deschide %1$s - .txt - 389 KO - 12:23:45 - Acesta este un substituent - 2012/05/18 12:23 PM stop comută Dezactivarea modului de economisire a bateriei ar putea rezulta în încărcarea de fișiere când bateria are un nivel scăzut! @@ -759,6 +753,7 @@ Înscriete cu un furnizor Permite %1$s să acceseze contul tău Nextcloud %2$s ? Sortare după + Sortați favoritele primele Ascunde Detalii Nu s-a putut verifica identitatea serverului diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8e51a61..2d91171 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -93,6 +93,7 @@ %1$s не поддерживает множественные аккаунты Не удается установить соединение Отменить вход + Пожалуйста, введите действующий адрес сервера. При обработке вашего запроса на вход возникла проблема. Пожалуйста, повторите попытку позже. Пожалуйста, завершите процесс входа в вашем браузере оставлен в исходном каталоге, т.к. файл только для чтения @@ -472,6 +473,7 @@ Папка уже существует Эту папку лучше всего просматривать в %1$s. Создать + Синхронизация … Здесь нет каталогов Имя папки не может быть пустым Выберите @@ -559,7 +561,6 @@ Очистить данные Настройки, сертификаты базы данных и сервера из данных %1$s будут удалены навсегда. \n\nЗагруженные файлы будут сохранены нетронутыми. \n\nЭтот процесс может занять некоторое время. Управление свободным местом - Вы достигли максимального лимита на загрузку файлов. Пожалуйста, загружайте не более 500 файлов одновременно. Невозможно организовать потоковую передачу медиафайла Медиафайл не может быть прочитан Медиафайл некорректно закодирован @@ -663,13 +664,6 @@ Не найдено приложений для обработки изображений Закрепить на главном экране Открыть %1$s - .txt - 389 Кбайт - заполнитель - 12:23:45 - Недавно изменено - Это заполнитель - 2012/05/18 12:23 остановить переключить Пожалуйста, выберите сервер... @@ -691,6 +685,7 @@ О программе Дополнительные Разрабатываемая версия + Файлы Основные Больше Синхронизировать @@ -816,7 +811,7 @@ Не удалось задать лимит загрузки. Пожалуйста, проверьте доступные возможности. Установить сообщение Создать заметку - Онлайн статус + Онлайн подключения Использовать изображение При настройке сквозного шифрования будет создана мнемофраза из 12 слов, которая понадобится для открытия файлов на других устройствах. Мнемофраза будет сохранена только на этом устройстве и может быть просмотрена повторно. Сохраните мнемофразу в надежном месте! Общий ресурс @@ -882,6 +877,8 @@ Подключиться через поставщика услуги Разрешить %1$s доступ к вашей учетной записи Nextcloud %2$s? Порядок сортировки + Сначала избранное + Начинать список с папок Скрыть Подробно Не удалось проверить подлинность сервера @@ -907,7 +904,7 @@ - Сертификат сервера не является доверенным - Срок действия сертификата сервера ещё не начался - URL не совпадает с именем сервера в сертификате - Сообщение о состоянии + Описание статуса Камера Выбрать расположение хранилища По умолчанию diff --git a/app/src/main/res/values-sc/strings.xml b/app/src/main/res/values-sc/strings.xml index d908fbc..200e619 100644 --- a/app/src/main/res/values-sc/strings.xml +++ b/app/src/main/res/values-sc/strings.xml @@ -471,12 +471,6 @@ Nega Sunt rechertos àteros permissos pro carrigare e iscarrigare documentos. Peruna aplicatzione agatada pro cunfigurare un\'immàgine - .txt - 389 KB - 12:23:45 - Modificados dae pagu - Custu est unu sostitutu temporàneu - 18/05/2012 12:23 firma parti Disativare su controllu de su rispàrmiu de energia podet fàghere chi si carrighent documentos cun sa bateria bàscia! @@ -496,6 +490,7 @@ In contu de Detàllios Dispositivu + Archìvios Generale Àteru Sincronizatzione @@ -632,6 +627,8 @@ Registra·ti cun su frunidore Permìtere a %1$s de intrare in su contu tuo de Nextcloud %2$s? Assenta segundu + Assenta cun is preferidos in antis + Assenta cun is cartellas in antis de is archìvios Cua Detàllios No at fatu a averguare s\'identidade de su serbidore diff --git a/app/src/main/res/values-sk-rSK/strings.xml b/app/src/main/res/values-sk-rSK/strings.xml index fbedb9b..cba4254 100644 --- a/app/src/main/res/values-sk-rSK/strings.xml +++ b/app/src/main/res/values-sk-rSK/strings.xml @@ -43,6 +43,7 @@ Brána proxy Zobrazí jeden widget z hlavného panela Hľadať v %s + Zdá sa byť offline Pridať novú úlohu Vytvorte novú úlohu vpravo dole Napíšte nejaký text @@ -540,7 +541,6 @@ Vyčistiť dáta Nastavenie, databázové a servrové certifikáty z dát %1$sbudú trvalo vymazané.\n\nStiahnuté súbory budú zachované.\n\nTento proces môže chvíľu trvať. Spravovať miesto - Dosiahli ste maximálny limit počtu súborov pre nahrávanie. Prosím nahrajte menej ako 500 súborov naraz. Multimediálny súbor sa nedá streamovať Nepodarilo sa prečítať súbor médií Súbor médií má neplatné kódovanie @@ -563,6 +563,7 @@ Nie je možné presunúť priečinok do samého seba Súbor už v cieľovom priečinku existuje Nie je možné presunúť súbor. Skontrolujte, či súbor existuje. + Stlmiť všetky upozornenia Pri čakaní na odpoveď servera nastala chyba. Operácia nemohla byť dokončená. Pri pokuse o pripojenie na server nastala chyba Pri čakaní na odpoveď servera nastala chyba. Operácia nemohla byť dokončená. @@ -637,12 +638,6 @@ Aplikácia na nastavenie obrázku sa nenašla Pripnúť na domovskú obrazovku Otvoriť %1$s - .txt - 389 KB - 12:23:45 - Nedávno upravené - Toto je \"placeholder\" - 2012/05/18 12:23 PM zastaviť prepnúť Prosím vyberte server ... @@ -663,6 +658,7 @@ O aplikácii Podrobnosti Vývojové + Súbory Všeobecné Viac Synchronizovať @@ -846,6 +842,8 @@ Zaregistrovať sa u poskytovateľa Umožniť %1$s pristupovať k Vášmu Nextcloud účtu %2$s? Zoradiť podľa + Zoradiť od najobľúbenejších + Zoradiť adresáre pred súbormi Skyť Podrobnosti Identu servra nemožno overiť diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index dbbb971..6e49f21 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -66,6 +66,7 @@ Gostitelja ni mogoče najti %1$s ne omogoča podpore več računom Ni mogoče vzpostaviti povezave + Prekliči prijavo ohranjeno v izvorni mapi, ker je ta le za branje Pošiljaj le na nemerjeni povezavi Wi-Fi /SamodejnoPošiljanje @@ -539,12 +540,6 @@ Za prejemanje oziroma pošiljanje datotek v oblak so zahtevana dodatna dovoljenja. Ni najdenega programa za nastavitev slike Odpri %1$s - .txt - 389 KB - 12:23:45 - Nedavno urejano - To je vsebnik predmetov - 2012/05/18 12:23 PM ustavi preklopi Onemogočanje varčevanja lahko povzroči pošiljanje datotek, ko je napetost baterije že nizka! @@ -562,6 +557,7 @@ O projektu Podrobnosti Dev + Datoteke Splošno Več Uskladi @@ -712,6 +708,8 @@ Prijava prek ponudnika Ali dovolite uporabniku %1$s dostop do računa Nextcloud %2$s? Razvrsti + Razvrsti najprej priljubljene + Razvrsti mape pred datotekami Skrij Podrobnosti Istovetnosti strežnika ni mogoče preveriti diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index 5b9cbab..8a7bb4b 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -402,10 +402,6 @@ Refuzo Që të ngarkoni dhe shkarkoni skedar kërkohen leje shtesë. Nuk u gjet asnjë aplikacion për të vendosur foton - 389 KB - 12:23:45 - Kjo është një vendmbajtëse - 2012/05/18 12:23 PM Çaktivizimi i kontrollit të ruajtjes së energjisë mund të rezultojë në ngarkimin e skedarëve kur gjendeni në gjendje të ulët të baterisë! të fshira mbajtur në dosjen origjinale @@ -415,6 +411,7 @@ Rreth Hollësi Dev + Skedarë Të përgjithshme Më tepër Sinkronizo diff --git a/app/src/main/res/values-sr-rSP/strings.xml b/app/src/main/res/values-sr-rSP/strings.xml index e530ade..f751cb9 100644 --- a/app/src/main/res/values-sr-rSP/strings.xml +++ b/app/src/main/res/values-sr-rSP/strings.xml @@ -391,10 +391,6 @@ Odbij Dodatne dozvole potrebne da se otpremaju i skidaju fajlovi. Nijedna aplikacija nije nađena da se sa njom postavi slika - 389 KB - 12:23:45 - Ovo je mestodržač - 2012/05/18 12:23 PoP Isključivanje provere uštede baterije može da dovede do toga da otpremate fajlove sa praznom baterijom! obrisano ostavljen u izvornoj fascikli @@ -404,6 +400,7 @@ O programu Podaci Razvojno + Fajlovi Opšte Ostalo Sinhronizuj diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 09bc76f..55ab68a 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -98,10 +98,14 @@ Дошло је до проблема при обради вашег захтева за пријаву. Молимо вас да касније покушате поново. Ниједан прегледач не може да отвори овај линк. Молимо вас да довршите процес пријаве у интернет прегледачу + Ауто-отпремање је паузирано јер је укључена Штедња батерије. остављен у оригиналној фасцикли јер је само за читање + Низак ниво батерије, отпремање би могло да потраје дуже Отпремај само на бежичним мрежама без ограничења /Аутоматска отпремања Овај фолдер је већ укључен у синхронизацију фолдера родитеља, па се могу јавити двострука отпремања + Чека се на Wi-Fi за почетак отпремања + Отпремају се фајлови са %s на %s Подеси Направи нову поставку за посебну фасциклу Подесите произвољну фасциклу @@ -205,6 +209,7 @@ Није успело покретање увоза. Молимо вас да покушате поново Није нађен фајл Не могу да нађем последњу резерву! + Откривају се измене садржаја Копирано у клипборд Грешка приликом копирања овог фајла или фасцикле Фасцикла се не може копирати у неку од својих потфасцикли. @@ -474,6 +479,11 @@ Фасцикла већ постоји Овај фолдер се најбоље приказује у %1$s. Направи + %1$d од %2$d · %3$s + Дошло је до грешке приликом синхронизације фолдера %s + Нема довољно простора на диску, синхронизација је отказана. + %s фолдер је успешно синхронизован + Синхронизација… Нема фасцикли овде Назив фолдера не може да буде празно Изаберите @@ -561,7 +571,6 @@ Очисти податке Поставке, база и серверски сертификати од података са %1$s ће бити заувек обрисани. \n\nВећ преузети фајлови неће бити дирани.\n\nОво може потрајати. Управљање простором - Достигли сте границу максималног отпремања фајлова. Молимо вас да отпремате мање од 500 фајлова истовремено. Медијски фајл не може да се пусти Не могу да прочитам медијски фајл Медијски фајл има неисправно кодирање знакова @@ -610,6 +619,8 @@ Не могу да извршим радњу. Прикажи обавештења за интеракцију са резултатом операција у позадини Операције у позадини + Открива локалне измене фајлова + Посматрач садржаја Приказ напретка преузимања Преузимања Приказ напретка синхронизације фајла и резултата @@ -665,13 +676,6 @@ Нема апликације којом се поставља слика Закачи на почетни екран Отвори %1$s - .txt - 389 KB - чувар места - 12:23:45 - Недавно уређивано - Ово је местодржач - 2012/05/18 12:23 ПоП стоп пребацивање Молимо вас да изаберете сервер @@ -693,6 +697,7 @@ О програму Детаљи Развојно + Фајлови Опште Остало Синхронизуј @@ -884,6 +889,8 @@ Пријавите се преко провајдера Може ли %1$s да приступи Некстклауд налогу %2$s? Разврстај + Сортирај прво омиљене + Поређај фолдере испред фајлова Сакриј Детаљи Идентитет сервера не може да се провери @@ -994,6 +1001,7 @@ Није пронађен догађај, увек можете да сихронизујете да бисте ажурирали. Преусмеравате се на веб Није пронађен контакт, увек можете да сихронизујете да бисте ажурирали. Преусмеравате се на веб За отварање резултата претраге су неопходне дозволе, у супротном бићете преусмерени на веб + У овом фолдеру Непознато Откључај фајл Постоје непрочитани коментари diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 713f6d6..b522e05 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -44,6 +44,7 @@ Visa en widget från dashboard Sök i %s Visa som frånkopplad + Detta innehåll genererades av AI och kan innehålla fel. Lägg till ny uppgift Skapa en ny uppgift från nedre högra hörnet Skriv någon text @@ -55,6 +56,7 @@ Ett fel uppstod när uppgiften skulle tas bort Uppgiften har raderats Uppgiftslistan är tom. + Uppgiftslistan är tom. Kontrollera konfigurationen för assistentappen. Det går inte att hämta uppgiftslistan, kontrollera din internetanslutning. Ta bort uppgift Uppgiftens resultat är inte redo än. @@ -93,12 +95,19 @@ %1$s har inte stöd för multipla konton Kunde inte upprätta anslutning Avbryt inloggning + Ange en giltig serveradress. + Kunde inte hämta inloggningsuppgifter. Försök igen. Det uppstod ett problem när din inloggningsförfrågan behandlades. Försök igen senare. + Ingen webbläsare finns tillgänglig för att öppna denna länk. Slutför inloggningsprocessen i din webbläsare + Automatisk uppladdning är pausad eftersom batterisparläge är aktiverat. behålls i originalmappen, eftersom den är skrivskyddad + Låg batterinivå, uppladdningen kan ta längre tid Ladda bara upp via obelastad Wi-Fi /AutomatiskUppladdning Denna mapp ingår redan i synkroniseringen av den överordnade mappen, vilket kan orsaka dubbla uppladdningar + Väntar på Wi-Fi för att starta uppladdning + Laddar upp filer från %s till %s Konfigurera Skapa en ny anpassad mappkonfiguration Skapa en anpassad mapp @@ -202,6 +211,7 @@ Importen kunde inte startas. Försök igen Ingen fil funnen Vi kunde inte hitta din senaste backup! + Upptäcker innehållsförändringar Kopierat till urklipp Ett fel uppstod, kunde inte kopiera filen eller mappen Det är inte möjligt att kopiera mappen till en av dess undermappar @@ -407,6 +417,8 @@ Inga sökresultat funna på din sökning Starta din sökning Skriv i sökfältet ovan för att hitta filer, kontakter, kalenderhändelser och mer i ditt konto. + Kontrollera din internetanslutning eller försök igen senare + Dålig anslutning mapp LIVE Läser in… @@ -471,6 +483,11 @@ Mapp finns redan Denna mapp visas bäst i %1$s. Skapa + %1$d av %2$d · %3$s + Ett fel uppstod under synkronisering av mappen %s + Otillräckligt diskutrymme, synkronisering avbruten + Mappen %s har synkroniserats + Synkroniserar... Inga mappar här Mappnamnet får inte vara tomt Välj @@ -558,7 +575,6 @@ Rensa data Inställningar, databas och servercertifikat från %1$s\'s data kommer att raderas permanent. \n\nHämtade filer kommer inte att raderas eller ändras.\n\nDenna process kan ta ett tag. Hantera utrymme - Du har nått den maximala filuppladdningsgränsen. Ladda upp färre än 500 filer åt gången. Media-filen kan inte strömmas Kunde inte läsa mediafilen Mediafilen har felaktig kodning @@ -607,6 +623,8 @@ Misslyckades utföra åtgärd. Visa aviseringar för att interagera resultatet av bakgrundsoperationer Bakgrundsoperationer + Upptäcker lokala filändringar + Innehållsövervakare Visar hämtningsförloppet Nerladdningar Visar filsynkroniserings förlopp och resultat @@ -647,6 +665,7 @@ Skriv in ditt lösenord Koden kommer efterfrågas varje gång du startar appen Vänligen ange ditt lösenord + Lösenkoden begärs varje gång appen öppnas eller öppnas igen efter 5 sekunder Koderna matchar inte Vänligen ange ditt lösenord igen Ta bort ditt lösenord @@ -662,13 +681,6 @@ Ingen app hittades för att ställa in en bild med Fäst på hemskärmen Öppna %1$s - .txt - 389 KB - platshållare - 12:23:45 - Nyligen redigerade - Detta är en platshållare - 2012/05/18 12:23 PM stopp växla Välj en server... @@ -690,6 +702,7 @@ Om Detaljer Dev + Filer Allmänt Mer Synkronisera @@ -882,6 +895,8 @@ Registrera hos en leverantör Tillåt %1$s att komma åt ditt Nextcloud-konto %2$s? Sortera efter + Sortera favoriter först + Sortera mappar före filer Göm Detaljer Webbplatsens identitet kunde inte verifieras @@ -992,6 +1007,7 @@ Händelsen hittades inte, du kan alltid synkronisera för att uppdatera. Omdirigerar till webben... Kontakten hittades inte, du kan alltid synkronisera för att uppdatera. Omdirigerar till webben... Behörigheter krävs för att öppna sökresultatet, annars omdirigeras det till webben... + I denna mapp Okänd Lås upp fil Olästa kommentarer finns @@ -1099,6 +1115,8 @@ Otillförlitligt servercertifikat Hämtar serverversion App avslutad + Överhoppad + En fil med samma namn finns redan. Slutförande Samma fil hittades på server, hoppar över uppladdning. Okänt fel @@ -1192,6 +1210,10 @@ %d fil kommer exporteras. Se avisering för detaljer. %d filer kommer exporteras. Se avisering för detaljer. + + Du kan bara ladda upp %d fil åt gången. + Du kan ladda upp max %d iler samtidigt. + %1$d mapp %1$d mappar diff --git a/app/src/main/res/values-sw/strings.xml b/app/src/main/res/values-sw/strings.xml index 79759d3..1c5561a 100644 --- a/app/src/main/res/values-sw/strings.xml +++ b/app/src/main/res/values-sw/strings.xml @@ -44,6 +44,7 @@ Inaonyesha wijeti moja kutoka kwa dashibodi Tafuta katika %s Tokea nje ya mtandao + Maudhui haya yalitolewa na AI na yanaweza kufanya makosa. Ongeza jukumu jipya Ongeza jukumu jipya kutoka chini kulia Andika maandishi kadhaa @@ -55,6 +56,7 @@ Hitilafu ilitokea wakati wa kufuta jukumu Jukumu limefutwa kwa mafanikio Orodha ya majukumu ni tupu. + Orodha ya majukumu iko tupu. Angalia usanidi wa programu ya Mratibu. Imeshindwa kuleta orodha ya kazi, tafadhali angalia muunganisho wako wa mtandao Futa jukumu Toleo la kazi bado halijawa tayari. @@ -93,12 +95,19 @@ %1$s haitumii akaunti nyingi Haikuweza kuanzisha muunganisho Ghairi uingiaji + Tafadhali weka anwani sahihi ya seva. + Imeshindwa kuleta maelezo ya kuingia. Tafadhali jaribu tena. Kulikuwa na tatizo la kuchakata ombi lako la kuingia. Tafadhali jaribu tena baadaye. + Hakuna kivinjari kinachopatikana ili kufungua kiungo hiki. Tafadhali kamilisha mchakato wa kuingia katika kivinjari chako + Upakiaji otomatiki umesitishwa kwa sababu Kiokoa Betri kimewashwa. iliyohifadhiwa kwenye folda asili, kwani inasomwa tu + Betri iko chini, upakiaji unaweza kuchukua muda mrefu Pakia kwenye Wi-Fi isiyopimwa pekee /Pakia Kiotomatiki Folda hii tayari imejumuishwa katika usawazishaji wa folda kuu, ambayo inaweza kusababisha upakiaji unaorudiwa + Inasubiri Wi-Fi kuanza kupakia + Inapakia faili kutoka %s hadi %s Sanidi Unda usanidi mpya wa folda maalum Sanidi folda maalum @@ -147,7 +156,7 @@ Hitilafu isiyojulikana Ondoa ushirikishaji huu Inapakia - Next + Ijayo Hapana Sasa SAWA @@ -202,6 +211,7 @@ Imeshindwa kuanza kuleta. Tafadhali jaribu tena Hakuna faili iliyopatikana Haikuweza kupata nakala yako ya mwisho! + Inagundua mabadiliko ya maudhui Nakili katika ubao wa kunakili Hitilafu ilitokea wakati wa kujaribu kunakili faili au folda hii Haiwezekani kunakili folda kwenye mojawapo ya folda zake za msingi @@ -407,6 +417,8 @@ Hakuna matokeo yaliyopatikana kwa swali lako Anza utafutaji wako Andika upau wa kutafutia hapo juu ili kupata faili, anwani, matukio ya kalenda na mengine kwenye akaunti yako. + Angalia muunganisho wako wa mtandao au ujaribu tena baadaye + Muunganisho hafifu folda MUBASHARA Inapakia @@ -471,6 +483,11 @@ Folda tayari ipo Folda hii inatazamwa vyema ndani %1$s. Tengeneza + %1$d of %2$d · %3$s + Hitilafu ilitokea wakati wa ulandanishi wa folda ya %s + Nafasi ya diski haitoshi, maingiliano yameghairiwa + Folda %s imesawazishwa + Inasawazisha... Hakuna folda hapa Jina la folda haliwezi kuwa tupu Chagua @@ -558,7 +575,6 @@ Futa data Mipangilio, hifadhidata na vyeti vya seva kutoka kwa data ya %1$s vitafutwa kabisa. \n\nFaili zilizopakuliwa zitawekwa bila kuguswa.\n\nMchakato huu unaweza kuchukua muda. Dhibiti nafasi - Umefikia kikomo cha juu zaidi cha kupakia faili. Tafadhali pakia chini ya faili 500 kwa wakati mmoja. Faili ya midia haiwezi kutiririshwa Haikuweza kusoma faili ya midia Faili ya midia ina usimbaji usio sahihi @@ -607,6 +623,8 @@ Imeshindwa kutekeleza kitendo. Onyesha arifa ili kuingiliana na matokeo ya shughuli za usuli Shughuli za usuli + Hutambua mabadiliko ya faili za ndani + Mtazamaji wa maudhui Inaonyesha maendeleo ya upakuaji Vipakuliwa Inaonyesha maendeleo ya usawazishaji wa faili na matokeo @@ -647,6 +665,7 @@ Weka nambari yako ya siri Nambari ya siri itaombwa kila wakati programu inapoanzishwa Tafadhali weka nenosiri lako + Nambari ya siri itaombwa kila wakati programu inapofunguliwa au kufunguliwa tena baada ya sekunde 5. Nambari za siri hazifanani Tafadhali ingiza tena nambari yako ya siri Futa nambari yako ya siri @@ -662,13 +681,6 @@ Hakuna programu iliyopatikana ya kuweka picha nayo Bandika kwenye skrini ya nyumbani Fungua %1$s - .txt - 389 KB - kishika nafasi - 12:23:45 - Imehaririwa hivi karibuni - Hiki ni kishikilia nafasi - 2012/05/18 12:23 PM acha geuza Tafadhali chagua seva... @@ -690,6 +702,7 @@ Kuhusu Maelezo ya kina Dev + Faili Kuu Zaidi Sawazisha @@ -844,7 +857,7 @@ Ingiza nenosiri Shiriki ya kiungo (%1$s) Panga tarehe ya mwisho wa matumizi - Set password + Weka nenosiri Kushiriki upya hakuruhusiwi wakati wa kudondosha faili salama Tafadhali chagua angalau chaguo moja la kushiriki kabla ya kuendelea. Inaweza kuhariri @@ -881,6 +894,8 @@ Jisajili na mtoa huduma Ruhusu %1$s kufikia akaunti yako ya Nextcloud %2$s? Panga kwa + Chagua za upendeleo kwanza + Chagua vikasha kabla ya mafaili Ficha Maelezo ya kina Utambulisho wa seva haukuweza kuthibitishwa @@ -991,6 +1006,7 @@ Tukio halijapatikana, unaweza kusawazisha kila wakati ili kusasisha. Inaelekeza kwenye wavuti... Anwani haijapatikana, unaweza kusawazisha kila wakati ili kusasisha. Inaelekeza kwenye wavuti... Ruhusa zinahitajika ili kufungua matokeo ya utafutaji vinginevyo yataelekezwa kwenye wavuti... + Katika folda hii Haijulikani Fungua faili Kuna maoni ambayo hayajasomwa @@ -1098,6 +1114,8 @@ Cheti cha seva isiyoaminika Inaleta toleo la seva... Programu imekatishwa + Imerukwa + Faili yenye jina sawa sawa tayari ipo. Imekamilika Faili sawa imepatikana kwenye kidhibiti cha mbali, ikiruka upakiaji Hitilafu isiyojulikana @@ -1191,6 +1209,10 @@ %d file will be exported. See notification for details. %d faili zitahamishwa. Tazama arifa kwa maelezo. + + You can upload only %d file at once. + Unaweza kupakia hadi faili %d mara moja. + %1$d folder %1$d folda diff --git a/app/src/main/res/values-th-rTH/strings.xml b/app/src/main/res/values-th-rTH/strings.xml index 025a5fb..1d98ed5 100644 --- a/app/src/main/res/values-th-rTH/strings.xml +++ b/app/src/main/res/values-th-rTH/strings.xml @@ -448,11 +448,6 @@ กรุณากรอกรหัสผ่านของคุณใหม่อีกครั้ง รหัสยืนยันที่เก็บไว้ รหัสยืนยันไม่ถูกต้อง - 389 KB - 12:23:45 - แก้ไขล่าสุด - นี่คือ placeholder - 2012/05/18 12:23 PM เก็บไว้ในโฟลเดอร์ต้นฉบับ ถูกย้ายไปยังโฟลเดอร์แอพพลิเคชัน เพิ่มบัญชี diff --git a/app/src/main/res/values-tk/strings.xml b/app/src/main/res/values-tk/strings.xml index 1471a16..79123de 100644 --- a/app/src/main/res/values-tk/strings.xml +++ b/app/src/main/res/values-tk/strings.xml @@ -427,10 +427,6 @@ inkär et Faýllary ýüklemek we göçürip almak üçin goşmaça rugsatlar gerek. Surat goýmak üçin hiç bir programma tapylmady - 389 KB - 12:23:45 - Bu bir ýer eýesi - 2012/05/18 12:23 Dur üýtgetmek Kuwwat tygşytlaýyş barlagyny öçürmek, batareýanyň pes ýagdaýynda faýl ýüklemegine sebäp bolup biler! diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 063a438..881041e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -93,12 +93,19 @@ %1$s birden çok hesabı desteklemiyor Bağlantı kurulamadı Oturum açmaktan vazgeç + Lütfen geçerli bir sunucu adresi yazın. + Oturum açma bilgileri alınamadı. Lütfen yeniden deneyin. Oturum açma isteğiniz işlenirken bir sorun çıktı. Lütfen bir süre sonra yeniden deneyin. + Bu bağlantıyı açabilecek bir tarayıcı bulunamadı. Lütfen oturum açma işlemini tarayıcınızdan tamamlayın + Pil koruma modu açık olduğundan otomatik yükleme duraklatıldı salt okunur olduğundan özgün klasörde kaldı + Pil azaldı. Yükleme işlemi uzun sürebilir Yalnızca kotasız kablosuz ağda yüklensin /OtomatikYükleme Bu klasör, üst klasörün eşitlemesine zaten katılmış olduğundan yinelenen yüklemelere neden olabilir. + Yüklemenin başlatılması için Wi-Fi bağlantısı bekleniyor + %s üzerindeki dosyalar %s üzerine yükleniyor Yapılandır Özel klasör kurulumu ekle Bir özel klasör kurun @@ -202,6 +209,7 @@ İçe aktarım başlatılamadı. Lütfen yeniden deneyin Herhangi bir dosya bulunamadı Son yedeğiniz bulunamadı! + İçerik değişiklikleri denetleniyor Panoya kopyalandı Bu dosya ya da klasör kopyalanmaya çalışılırken bir sorun çıktı Bir klasör kendi alt klasörü olarak kopyalanamaz @@ -471,6 +479,11 @@ Klasör zaten var Bu klasör en iyi %1$s ile görüntülenir. Ekle + %1$d / %2$d · %3$s + %s klasörü eşitlenirken bir sorun çıktı + Disk alanı yetersiz. Eşitleme iptal edildi + %s klasörü eşitlendi + Eşitleniyor… Burada herhangi bir klasör yok Klasör adı boş olamaz Seçin @@ -558,7 +571,6 @@ Verileri temizle %1$s verilerinden ayarlar, veri tabanı ve sunucu sertifikaları kalıcı olarak silinecek. \n\nİndirilen dosyalara dokunulmayacak.\n\nBu işlem biraz zaman alabilir. Alan yönetimi - Yüklenebilecek dosya sayısı sınırına ulaştınız. Lütfen bir seferde 500 taneden az dosya yükleyin. Ortam dosyası akışı sağlanamadı Ortam dosyası okunamadı Ortam dosyası doğru şekilde kodlanmamış @@ -607,6 +619,8 @@ İşlem yapılamadı. Arka plan işlemlerinin sonucuyla etkileşim kurmak için bildirimleri görüntüle Arka plan işlemleri + Yerel dosya değişikliklerini algılar + İçerik izleyici İndirme ilerlemesini görüntüler İndirmeler Dosya eşitleme ilerlemesi ve sonuçlarını görüntüler @@ -662,13 +676,6 @@ Görselin ayarlanabileceği bir uygulama bulunamadı Ana sayfaya sabitle %1$s aç - .txt - 389 KB - yerbelirtici - 12:23:45 - Son düzenlenenler - Bu bir yer belirleyicidir - 2012/05/18 12:23 ÖS durdur değiştir Lütfen bir sunucu seçin… @@ -690,6 +697,7 @@ Hakkında Ayrıntılar Geliştirici + Dosyalar Genel Daha fazla Eşitle @@ -881,6 +889,8 @@ Hizmet sağlayıcı ile hesap aç %1$s, %2$s Nextcloud hesabınıza erişebilsin mi? Sıralama + Sık kullanılanlar üstte sıralansın + Klasörler dosyaların üzerinde sıralansın Gizle Ayrıntılar Sunucunun kimliği doğrulanamadı @@ -991,6 +1001,7 @@ Etkinlik bulunamadı, güncellemek için eşitleyebilirsiniz. İnternet üzerine yönlendiriliyor… Kişi bulunamadı, güncellemek için eşitleyebilirsiniz. İnternet üzerine yönlendiriliyor… Arama sonuçlarını açmak için izinler gereklidir. Yoksa İnternet üzerine yönlendirilir… + Bu klasör içindeki Bilinmiyor Dosyanın kilidini aç Okunmamış yorumlar var diff --git a/app/src/main/res/values-ug/strings.xml b/app/src/main/res/values-ug/strings.xml index 235c3c9..1f46d85 100644 --- a/app/src/main/res/values-ug/strings.xml +++ b/app/src/main/res/values-ug/strings.xml @@ -337,6 +337,7 @@ يۆتكەش چۈشۈرۈش يۈكلەش + سىرتقى ھەمبەھىر قوشۇش ياكى يوللاش باشقۇرغۇچىنى چۈشۈرۈش ئۈچۈن ھۆججەت يوللىيالمىدى ھۆججەت بېسىش مەغلۇب بولدى @@ -471,6 +472,7 @@ مەۋجۇت ھۆججەتلەرنىمۇ يۈكلەڭ توك قاچىلىغاندا ئاندىن يۈكلەڭ / InstantUpload + ئىچكى ھەمبەھىر ئىچكى ئىككى خىل ماسقەدەملەش تېخى ئەمەس ، ئۇزۇن ئۆتمەي ماسقەدەملىنىدۇ شىفىرلانغان ھۆججەت قىسقۇچنى ئورنىتىش ئۈچۈن تور ئۇلىنىشى تەلەپ قىلىنىدۇ @@ -509,7 +511,6 @@ سانلىق مەلۇماتنى تازىلاش %1$s نىڭ سانلىق مەلۇماتلىرىدىن تەڭشەك ، ساندان ۋە مۇلازىمېتىر گۇۋاھنامىسى مەڭگۈلۈك ئۆچۈرۈلىدۇ. \ n \ n چۈشۈرۈلگەن ھۆججەتلەر تۇتۇلمايدۇ. \ n \ n بۇ جەريانغا بىر ئاز ۋاقىت كېتىدۇ. بوشلۇقنى باشقۇرۇش - ھۆججەت يوللاشنىڭ ئەڭ يۇقىرى چېكىگە يەتتىڭىز. بىر قېتىمدا 500 دىن ئاز ھۆججەت يوللاڭ. مېدىيا ھۆججىتىنى ئاقلاشقا بولمايدۇ مېدىيا ھۆججىتىنى ئوقۇيالمىدى مېدىيا ھۆججىتىدە كودلاش خاتا @@ -598,12 +599,6 @@ ھۆججەتلەرنى يوللاش ۋە چۈشۈرۈش ئۈچۈن قوشۇمچە ئىجازەتلەر. رەسىم ئورنىتىدىغان ھېچقانداق دېتال تېپىلمىدى %1$s نى ئېچىڭ - .txt - 389 KB - 12:23:45 - يېقىندا تەھرىرلەندى - بۇ يەر ئىگىسى - 2012/05/18 12:23 PM توختا toggle مۇلازىمېتىرنى تاللاڭ… @@ -624,6 +619,7 @@ ھەققىدە تەپسىلاتى Dev + ھۆججەتلەر ئادەتتىكى تېخىمۇ كۆپ ماسقەدەملەش @@ -794,6 +790,8 @@ تەمىنلىگۈچى بىلەن تىزىملىتىڭ %1$s نىڭ Nextcloud ھېساباتىڭىزنى زىيارەت قىلىشىغا يول قويۇڭ%2$s? تەرتىپلەش + ياقتۇرىدىغانلارنى رەتلەڭ + ھۆججەتلەرنى ھۆججەتتىن بۇرۇن تەرتىپلەڭ يوشۇر تەپسىلاتى مۇلازىمېتىرنىڭ سالاھىيىتىنى دەلىللىگىلى بولمىدى @@ -1033,6 +1031,22 @@ ئېلېكترونلۇق خەت ئەۋەتىڭ سانلىق مەلۇمات ساقلاش قىسقۇچى مەۋجۇت ئەمەس! بۇ بەلكىم باشقا ئۈسكۈنىدە زاپاسلاشنى ئەسلىگە كەلتۈرگەن بولۇشى مۇمكىن. سۈكۈتكە قايتىش. سانلىق مەلۇمات ساقلاش قىسقۇچىنى تەڭشەش ئۈچۈن تەڭشەكلەرنى تەكشۈرۈڭ. + + %d سائەت + %d سائەت + + + %d مىنۇت + %d مىنۇت + + + %d مىنۇت ئىلگىرى + %d مىنۇت ئىلگىرى + + + %d سائەت ئىلگىرى + %d سائەت ئىلگىرى + Could not sync %1$d file (conflicts: %2$d) Could not sync %1$d files (conflicts: %2$d) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 0b6fb14..3e5972b 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -93,12 +93,19 @@ %1$s не підтримує одночасно декілька облікових записів Не вдалося встановити з\'єднання Скасувати авторизацію + Зазначте дійсну адресу сервера + Не вдалося отримати дані для входу. Спробуйте ще раз. Помилка під час обробки запиту на вхід. Спробуйте пізніше. + Відсутній бравзер для відкриття цього посилання. Завершити авторизацію у бравзері + Автозавантаження призупинено, оскільки увімкнено заощадження батареї. залишено у вихідному каталозі, оскільки він доступний лише для читання + Низький заряд батареї, завантаження може тривати доше Завантаження тільки через WiFi /AutoUpload Цей каталог вже включено до синхронізації каталогу вищого рівня, що може призвести до задвоєння завантаження + Очікується з\'єднання з WiFi для початку завантаження + Завантаження файлів з %s до %s Налаштування Власний каталог користувача Каталог користувача @@ -202,6 +209,7 @@ Не вдалося здійснити імпорт. Спробуйте ще раз Файл не знайдено Неможливо знайти вашу останню резервну копію + Пошук змін у вмісті Скопійовано Виникла помилка під час спроби копіювати цей файл або каталог Неможливо скопіювати каталог до одного із вкладених підкаталогів @@ -407,6 +415,8 @@ За вашим запитом нічого не знайдено Почніть пошук Зазначте в полі пошуку для пошуку файлів, контактів, календарних подій тощо серед ваших облікових записів. + Перевірте з\'єднання з мережею або спробуйте пізніше + Слабке з\'єднання каталог НАЖИВО Завантаження… @@ -471,6 +481,11 @@ Каталог вже існує Переглядайте каталог у %1$s. Створити + %1$d із %2$d · %3$s + Помилка під час синхронізації каталогу %s + Недостатньо місця на диску, синхронізацію скасовано + %s каталог успішно синхронізовано + Синхронізація... Тут відсутні каталоги Ім\'я каталогу не може бути порожнім Вибрати @@ -558,7 +573,6 @@ Очистити дані Налаштування, база даних та дані сертифікатів серверу від %1$s буде вилучено без можливості відновлення.\n\nФайли, які було звантажено, буде збережено.\n\nЦей процес триватиме певний час. Керувати простором - Досягнуто обмеження на кількість завантаження файлів. За раз можна завантажувати не більше 500 файлів. Неможливо транслювати мультимедійний файл Неможливо відкрити мультимедійний файл Мультимедійний файл має неправильно кодування @@ -607,6 +621,8 @@ Не вдалося виконати дію. Показувати сповіщення про результати фонових операцій Фонові операції + Пошук змін у файлі на пристрої + Оглядач вмісту Показує перебіг звантаження Звантаження Показує перебіг синхронізації файлів та результати @@ -662,12 +678,6 @@ Не знайдено застосунків для встановлення зображення для Закріпити на домівці Відкрити %1$s - .txt - 389 КБ - 12:23:45 - Нещодавно відредаговано - Це заповнювач - 2012/05/18 12:23 PM стоп переключитися Виберіть сервер... @@ -689,6 +699,7 @@ Про застосунок Деталі Розробка + Файли Основне Більше Синхронізація @@ -880,6 +891,8 @@ Зареєструватися у провайдейра Дозволити %1$s мати доступ до вашого облікового запису у Nextcloud %2$s? Впорядкувати за + Спочатку показувати із зірочкою + Показувати каталоги перед файлами Сховати Деталі Неможливо перевірити ідентифікатор сервера. @@ -990,6 +1003,7 @@ Подію не знайдено. Синхронізуйтеся для оновлення. Переспрямування на вебсайт... Контакт не знайдено. Синхронізуйтеся для оновлення. Переспрямування на вебсайт... Для отримання результатів пошуку потрібні окреми дозволи, або перейти на вебсайт... + У цьому каталозі Невідомо Розблокувати файл Доступні непрочитані коментарі @@ -1097,6 +1111,8 @@ Не довірений сертифікат серверу Отримую версію сервера... Застосунок зупинено + Пропущено + Файл з таким саме ім\'ям вже присутній Завершені Такий саме файл знайдено у хмарі, пропускаю завантаження Невідома помилка @@ -1220,6 +1236,12 @@ %d файлів буде експортовано. Дивіться сповіщення для деталей. %d файлів буде експортовано. Дивіться сповіщення для деталей. + + Одночасно можна завантажити лише %d файл. + Одночасно можна завантажити до %d файлів. + Одночасно можна завантажити до %d файлів. + Одночасно можна завантажити до %d файлів. + %1$d тека %1$d тек diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 1ca1525..16e6f5e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -493,12 +493,6 @@ Các quyền bổ sung cần thiết để tải lên và tải xuống tệp. Không tìm thấy ứng dụng nào để đặt ảnh với Mở trong %1$s - .txt - 389 KB - 12:23:45 - Chỉnh sửa gần đây - Vị trí này đã được đặt chỗ trước - 2012/05/18 12:23 PM dừng bật/tắt Việc tắt kiểm tra tiết kiệm năng lượng có thể dẫn đến việc tải tệp lên khi ở trạng thái pin yếu! @@ -516,6 +510,7 @@ Thông tin Chi tiết Phát triển + Tệp Tin Tổng hợp hơn Đồng bộ @@ -651,6 +646,8 @@ Đăng ký với nhà cung cấp Cho phép %1$s truy cập vào %2$s tài khoản Hệ thống của bạn Sắp xếp theo + Sắp xếp mục yêu thích trước + Sắp xếp thư mục trước tập tin Ẩn Chi tiết Không thể xác minh danh tính của máy chủ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b4156a0..9f915ab 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -93,12 +93,19 @@ %1$s 不支持多个账号 无法建立连接 取消登录 - 处理你的登录请求时出现问题。请稍后再试。 + 请输入有效的服务器地址。 + 无法获取登录详情。请重试。 + 处理您的登录请求时出现问题。请稍后重试。 + 没有浏览器可以打开此链接。 请在浏览器中完成登录流程 + 自动上传已暂停,因为省电模式已开启。 保持为原始的文件夹,即使它是只读的 + 电池电量低,上传可能需要更长时间 仅通过无流量限制的 Wi-Fi 上传 /自动上传 此文件夹已包含在上级文件夹的同步中,这可能会导致重复上传 + 正在等待 Wi-Fi 开始上传 + 正在将文件从 %s 上传到 %s 配置 创建新的自定义文件夹设定 设置一个自定义文件夹 @@ -202,6 +209,7 @@ 导入无法开始。请重试 没有文件被发现 无法找到末次备份 + 正在检测内容更改 复制到剪贴板 尝试复制这个文件或文件夹时发生了错误 将文件夹复制到其自己的底层文件夹中是不可能的 @@ -407,6 +415,8 @@ 未找到查询的结果 开始搜索 在上方搜索栏中输入以查找您账号中的文件、联系人、日历事件等。 + 检查你的网络连接或稍后再试 + 连接差 文件夹 即时 正在加载... @@ -471,6 +481,11 @@ 目录已经存在 此文件夹最好在 %1$s 中查看。 创建 + %1$d / %2$d · %3$s + 同步 %s 文件夹时出错 + 磁盘空间不足,同步已取消 + %s 文件夹已成功同步 + 正在同步… 这里没有文件夹 文件夹名称不能为空 选择 @@ -558,7 +573,6 @@ 清除数据 来自%1$s数据的设置,数据库和服务器证书将被永久删除。\n\n下载的文件将保持不变。\n\n此过程可能需要一段时间。 管理空间 - 你已达到文件上传数量限制,单次请勿超过500个文件。 此媒体文件无法流播 无法读取媒体文件 媒体文件的编码不正确 @@ -607,6 +621,8 @@ 执行动作失败。 显示通知以便与后台操作的结果交互 后台操作 + 检测本地文件更改 + 内容观察器 显示下载进度 下载 显示文件同步进度和结果 @@ -662,13 +678,6 @@ 找不到可以设置图片的应用程序 固定到主屏幕 打开 1%1$s - .txt - 389 KB - 占位符 - 12:23:45 - 最近编辑 - 这是一个占位符 - 2012/05/18 下午12:23 停止 切换 请选择服务器… @@ -690,6 +699,7 @@ 关于 详细信息 开发 + 文件 常规 更多 同步 @@ -881,6 +891,8 @@ 与提供商签约 是否允许%1$s访问您的 Nextcloud 账号%2$s? 排序方式 + 收藏排序优先 + 将文件夹排在文件前面 隐藏 详情 无法验证服务器身份 @@ -994,6 +1006,7 @@ 未找到活动,你随时可以同步更新。正在重定向至网络… 未找到联系人,你可以随时同步更新。正在重定向至网络… 打开搜索结果需要权限,否则它将被重定向至网页… + 在此文件夹中 未知 解锁文件 有未读评论 @@ -1101,6 +1114,8 @@ 不受信任的服务器证书 正在获取服务器版本… 应用程序已终止 + 跳过 + 相同名称的文件已存在。 已完成 服务端已存在相同的文件,跳过上传 未知错误 @@ -1127,7 +1142,7 @@ 有什么新图片 跳过 新建%1$s - 你什么状态? + 您的状态如何? 启用仪表盘应用程序后,小部件仅在 %1$s 25 或更高版本中可用 不可用 正在下载文件… @@ -1179,6 +1194,9 @@ %d个文件将被导出。详情见通知。 + + 你一次可以最多上传 %d 个文件。 + %1$d个文件夹 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 9225635..2a8372f 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -44,6 +44,7 @@ 顯示儀表板中的一個小部件 %s內搜尋 顯示為離線 + 此內容為人工智能產生,可能有錯。 添加新任務 從右下角創建新任務 輸入一些文字 @@ -55,6 +56,7 @@ 刪除任務時發生了錯誤 任務已刪除 任務清單是空的。 + 任務清單是空的。請檢查小幫手應用程式設定。 無法擷取任務清單,請檢查您的網際網路連線。 刪除任務 任務輸出尚未就緒。 @@ -93,12 +95,19 @@ %1$s 不支援多個帳戶 無法建立連線 取消登入 + 請輸入有效的伺服器地址。 + 無法擷取登入詳細信息。請再試一次。 處理您的登入請求時出現問題。請稍後再試。 + 沒有瀏覽器可以開啟此連結。 請在瀏覽器中完成登入流程 + 因為省電模式已開啟,因此暫停自動上傳。 以唯讀模式保留在原本的資料夾 + 低電量,上傳可能會需要較長時間 僅在未計量的Wi-Fi上傳 自動上傳 此資料夾已包含在父資料夾的同步中,這可能會導致重複上傳 + 等待 Wi-Fi 開始上傳 + 從 %s 上傳檔案至 %s 設定 新增自訂資料夾 設定自訂資料夾 @@ -202,6 +211,7 @@ 匯入無法開始。請再試一次 無檔案 找不到您最新的備份 + 偵測內容變更 已複製到剪貼板 嘗試複製檔案或資料夾時發生錯誤 無法將資料夾複製到自己的子資料夾中。 @@ -407,6 +417,8 @@ 未找到與您的查詢相符的結果 開始搜尋 在上方搜索欄中輸入內容,以在您的帳戶中尋找檔案、聯絡人、日曆活動等。 + 請檢查您的網際網路連線或稍後再試 + 連線品質不佳 資料夾 直播 載入中… @@ -471,6 +483,11 @@ 資料夾已存在 此資料夾在 %1$s 中顯示效果最佳。 建立 + %1$d / %2$d · %3$s + 同步 %s 資料夾時發生錯誤 + 磁碟空間不足,已取消同步 + 已成功同步 %s 資料夾 + 同步中 ... 這裡沒有資料夾 資料夾名稱不能為空 選擇 @@ -558,7 +575,6 @@ 清除資料 來自%1$s的設定,數據庫與伺服器憑證相關資料將被永久刪除,已下載的檔案將不會變動,此過程需要花費一些時間。 管理空間 - 您已達最大檔案上傳限制。一次上傳僅能上傳少於 500 個檔案。 此媒體檔案無法被串流播放 無法讀取媒體檔案 媒體檔案未被正確的編碼 @@ -607,6 +623,8 @@ 無法執行操作。 顯示通知以與背景操作的結果互動 背景操作 + 偵測近端檔案變更 + 內容觀察器 顯示下載進度 下載 顯示檔案同步進度和結果 @@ -647,6 +665,7 @@ 輸入通行碼 每次應用程式開啟時,都需要輸入通行碼 請輸入通行碼 + 每次應用程式開啟或 5 秒後重新開啟時,都需要輸入通行碼。 兩個通行碼不相符 再次輸入通行碼 刪除您的通行碼 @@ -662,13 +681,6 @@ 沒有找到編輯圖片的應用程式 釘選至主畫面 公開 %1$s - .txt - 389 KB - 佔位字串 - 12:23:45 - 最近編輯 - 這是佔位內容 - 2012/05/18 12:23 PM 停止 切換 請選擇伺服器 ... @@ -690,6 +702,7 @@ 關於 詳細資料 開發版 + 檔案 一般 更多 同步 @@ -881,6 +894,8 @@ 使用第三方登入 允許 %1$s 存取您的 Nextcloud 帳戶 %2$s? 排序方式 + 先排序最愛 + 將資料夾在檔案之前排序 隱藏 細節 無法驗證伺服器身分 @@ -991,6 +1006,7 @@ 找不到活動,您隨時可以同步更新。正在重新導向至網路 … 找不到聯絡人,您隨時可以同步更新。正在重新導向至網路 … 開啟搜尋結果需要權限,否則其將會重新導向至網路 … + 在此資料夾 不詳 解鎖檔案 有未讀留言 @@ -1098,6 +1114,8 @@ 不信任的伺服器憑證 取得伺服器的版本中... App 中斷運作 + 略過 + 已存在同名檔案。 完成 遠端找到相同的檔案,跳過上傳。 錯誤不詳 @@ -1176,6 +1194,9 @@ %d 個檔案將被導出。 詳情見通知。 + + 您一次最多可以上傳 %d 個檔案。 + %1$d個資料夾 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a80fb6d..eb253e7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -44,6 +44,7 @@ 顯示儀表板中的一個小工具 搜尋 %s 顯示為離線 + 此內容由人工智慧產生,可能會出錯。 新增任務 從右下角建立新任務 輸入一些文字 @@ -55,6 +56,7 @@ 刪除工作項目時發生錯誤 已成功刪除工作項目 任務清單為空。 + 任務清單是空的。請檢查小幫手應用程式設定。 無法擷取工作項目清單,請檢查您的網際網路連線。 刪除工作項目 任務輸出尚未就緒。 @@ -98,10 +100,14 @@ 處理您的登入請求時出現問題。請稍後再試。 沒有瀏覽器可以開啟此連結。 請在瀏覽器中完成登入流程 + 因為省電模式已開啟,因此暫停自動上傳。 以唯讀模式保留在原本的資料夾 + 低電量,上傳可能會需要較長時間 只在非計量收費的 Wi-Fi 上傳 /AutoUpload 此資料夾已包含在上層資料夾的同步中,這可能會導致重複上傳 + 等待 Wi-Fi 開始上傳 + 從 %s 上傳檔案至 %s 設定 新增自訂資料夾 設置自訂資料夾 @@ -205,6 +211,7 @@ 匯入無法開始。請再試一次 找不到檔案 找不到您最新的備份! + 偵測內容變更 已複製到剪貼簿 嘗試複製此檔案或資料夾時發生錯誤 無法將資料夾複製到自己的子資料夾中 @@ -410,6 +417,8 @@ 找不到與您的查詢相符的結果 開始搜尋 在上方搜尋列輸入以在您的帳號中尋找檔案、聯絡人、日曆事件等。 + 請檢查您的網際網路連線或稍後再試 + 連線品質不佳 資料夾 即時 正在載入… @@ -474,6 +483,11 @@ 資料夾已經存在 此資料夾最好在 %1$s 中檢視。 建立 + %1$d / %2$d · %3$s + 同步 %s 資料夾時發生錯誤 + 磁碟空間不足,已取消同步 + 已成功同步 %s 資料夾 + 正在同步…… 此處無資料夾 資料夾名稱不能為空 選擇 @@ -561,7 +575,6 @@ 清除資料 來自 %1$s 的設定,資料庫與伺服器憑證等相關資料將永久刪除。\n\n已下載的檔案將保留不會變動。\n\n此過程需要一點時間。 管理空間 - 您已達最大檔案上傳限制。一次上傳僅能上傳少於 500 個檔案。 此媒體檔案無法以串流播放 無法讀取媒體檔案 媒體檔案編碼不正確 @@ -610,6 +623,8 @@ 執行動作失敗。 顯示通知以與背景操作的結果互動 背景操作 + 偵測本機檔案變更 + 內容觀察器 顯示下載進度 下載 顯示檔案同步進度和結果 @@ -650,6 +665,7 @@ 請輸入通行碼 每次應用程式開啟時,都需要輸入通行碼 請輸入通行碼 + 每次應用程式開啟或 5 秒後重新開啟時,都需要輸入通行碼。 兩個通行碼不相符 再次輸入通行碼 刪除您的通行碼 @@ -665,13 +681,6 @@ 沒有找到圖片可設定的應用程式 釘選至主畫面 開啟 %1$s - .txt - 389 KB - 佔位字串 - 12:23:45 - 最近編輯 - 這是佔位內容 - 2012/05/18 12:23 PM 停止 切換 請選取伺服器…… @@ -693,6 +702,7 @@ 關於 詳細資料 開發版 + 檔案 一般 更多 同步 @@ -884,6 +894,8 @@ 向提供者註冊 允許 %1$s 存取您的 Nextcloud 帳號 %2$s? 排序依照 + 先排序喜愛 + 將資料夾排序在檔案前 隱藏 詳細資訊 無法驗證伺服器身份 @@ -994,6 +1006,7 @@ 找不到活動,您隨時可以同步更新。正在重新導向至網路…… 找不到聯絡人,您隨時可以同步更新。正在重新導向至網路…… 開啟搜尋結果需要權限,否則其將會重新導向至網路…… + 在此資料夾 未知 解鎖檔案 有未讀留言 @@ -1101,6 +1114,8 @@ 不信任的伺服器憑證 正在擷取伺服器版本… 應用程式已終止 + 略過 + 已有相同名稱的檔案。 已完成 遠端找到相同的檔案,略過上傳 未知的錯誤 @@ -1179,6 +1194,9 @@ 將匯出 %d 個檔案。請查看通知了解更多資訊。 + + 您一次可以上傳最多 %d 個檔案。 + %1$d 個資料夾 diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index d320c92..1394d25 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -29,14 +29,18 @@ 32dp 8dp 4dp + 32dp 2dp 16dp 32dp + 64dp 8dp 100dp 100dp 4dp + 14dp + 12sp 20dp 10dp @@ -74,7 +78,6 @@ 10dp 5dp 10dp - 10dp 5dp 55dp 32dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc542e1..ec872c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,8 +10,8 @@ Favorites Assistant Media - - + Poor connection + Check your internet connection or try again later %1$s Android app version %1$s version %1$s, build #%2$s @@ -56,7 +56,7 @@ Unable to fetch task types, please check your internet connection. Unable to fetch task list, please check your internet connection. - Task list is empty. + Task list is empty. Check assistant app configuration. Assistant Loading task list… @@ -78,6 +78,8 @@ Input Output + This content was generated by AI and can make mistakes. + Recommended files Assistant @@ -172,7 +174,6 @@ File selected for upload not found. Please check whether the file exists. Could not copy file to a temporary folder. Try to resend it. Upload option: - You have reached the maximum file upload limit. Please upload fewer than 500 files at a time. %s already exists, no conflict detected Move file to %1$s folder Keep file in source folder @@ -237,6 +238,12 @@ %1$d / %2$d - %3$s + Syncing… + %s folder successfully synchronized + An error occurred during synchronization of the %s folder + %1$d of %2$d · %3$s + Insufficient disk space, synchronization canceled + Uploading… %1$d%% Uploading %2$s %1$s uploaded @@ -250,6 +257,9 @@ Cancelled Uploaded Completed + Skipped + A file with the same name already exists. + Same file found on remote, skipping upload Cancelled Connection error @@ -328,6 +338,10 @@ %d file will be exported. See notification for details. %d files will be exported. See notification for details. + + You can upload only %d file at once. + You can upload up to %d files at once. + As of version 1.3.16, files uploaded from this device are copied into the local %1$s folder to prevent data loss when a single file is synced with multiple accounts.\n\nDue to this change, all files uploaded with earlier versions of this app were copied into the %2$s folder. However, an error prevented the completion of this operation during account synchronization. You may either leave the file(s) as is and delete the link to %3$s, or move the file(s) into the %1$s folder and retain the link to %4$s.\n\nListed below are the local file(s), and the remote file(s) in %5$s they were linked to. The folder %1$s does not exist anymore Move all @@ -341,7 +355,7 @@ Credentials disabled Enter your passcode - The passcode will be requested every time the app is started + The passcode will be requested every time the app is opened or reopened after 5 seconds. Please reenter your passcode Delete your passcode The passcodes are not the same @@ -451,13 +465,69 @@ The certificate could not be shown. - No information about the error - This is a placeholder - placeholder - Recently edited - .txt - 389 KB - 2012/05/18 12:23 PM - 12:23:45 + 2012/05/18 12:23 PM + 12:23:45 + This is a placeholder + Filename + Recently edited + .txt + 389 KB + 5 MB + 3 MB + Current (2) + Send button + Firstname Lastname + Filetype + Download + Internal share link only works for users with access to this folder + Internal share link only works for users with access to this folder + All 12 words together make a very strong password, letting only you view and make use of your encrypted files. Please write it down and keep it somewhere safe. + No internet connection + ☁️ My custom status + https://nextcloud.localhost/nextcloud + firstname@example.nextcloud.com + 12. Dec 2020 - 23:10:20 + 10. Dec 2020 - 10:10:10 + Grant Nextcloud News access to your Nextcloud account incrediblyLong_username_with_123456789_number@Nextcloud_dummy.com? + +49 123 456 789 12 + d7edb387-0b61-4e4e-a728-ffab3055d700 + Job name + ENQUEUED + 2020-02-15T20:53:15Z + 50% + 0 + 5 downloads remaining + View only + 6 min ago + Success + /abc/example + Delete file + Some additional action info + Open in Notes + This folder is best viewed in the Notes app + 2025 + October + For /storage/emulated/0/DCIM/Camera + 📆 + In a meeting + an hour + Wednesday • 26 Jul 2023 • 12:27 + 12 MP • 3024 × 4032 • 923 KB + Camera Phone (4th generation) + ƒ/1.8 • 1/374 s • 28 mm • ISO 200 + Mitte, Berlin, Germany + © OpenStreetMap contributors + Compose email + passphrase + Show 3 hidden folders + Template + First line + Subline + Search in Nextcloud + Files + in TestFolder + example@nextcloud.com + Low battery, upload might take longer Only upload on unmetered Wi-Fi Only upload when charging @@ -465,6 +535,9 @@ /InstantUpload /AutoUpload + Low battery, upload might take longer + Waiting for Wi-Fi to start uploading + File is currently locked by another user or process and therefore not deletable. Please try again later. Sorry @@ -791,6 +864,7 @@ shared More menu Type + Sync status button Sync warning button Settings button @@ -822,6 +896,9 @@ Offline operations Shows progress of offline file operations + Detecting content changes + Content observer + Detects local file changes Account not found! @@ -878,7 +955,7 @@ Show notifications to interact result of background operations Show push notifications sent by the server: Mentions in comments, reception of new remote shares, announcements posted by an admin etc. Send button icon - + Uploading files from %s to %s * Name Password @@ -1318,6 +1395,7 @@ Not yet, soon to be synced Google restricted downloading APK/AAB files! No file or folder matching your search + In this folder Event not found, you can always sync to update. Redirecting to web… Contact not found, you can always sync to update. Redirecting to web… Permissions are required to open search result otherwise it will redirected to web… @@ -1356,6 +1434,7 @@ Create link This folder is best viewed in %1$s. Open in %1$s + Auto-upload is paused because Battery Saver is on. This folder is already included in the parent folder’s sync, which may cause duplicate uploads Sync anyway Sync duplication @@ -1383,4 +1462,7 @@ Clear Set message Error setting status message! + Sort folders before files + Sort favorites first + Files diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 51549b9..2a8427f 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -6,13 +6,15 @@ ~ SPDX-FileCopyrightText: 2017 Andy Scherzinger ~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only --> - - + + - + - \ No newline at end of file + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 63a1902..8ab1f37 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -34,15 +34,28 @@ android:key="synced_folders_configure_folders"/> + + + + + + - (relaxed = true) @Mock private lateinit var params: WorkerParameters @@ -99,11 +104,22 @@ class BackgroundJobFactoryTest { @Mock private lateinit var syncedFolderProvider: SyncedFolderProvider + @Mock + private lateinit var db: NextcloudDatabase + + @Mock private lateinit var fileDao: FileDao + private lateinit var factory: BackgroundJobFactory @Before fun setUp() { - MockitoAnnotations.initMocks(this) + mockkStatic(MainApp::class) + every { MainApp.getAppContext() } returns context + + MockitoAnnotations.openMocks(this) + + whenever(db.fileDao()).thenReturn(fileDao) + factory = BackgroundJobFactory( logger, preferences, @@ -111,7 +127,6 @@ class BackgroundJobFactoryTest { clock, powerManagementService, { backgroundJobManager }, - deviceInfo, accountManager, resources, dataProvider, @@ -123,7 +138,8 @@ class BackgroundJobFactoryTest { { viewThemeUtils }, { localBroadcastManager }, generatePDFUseCase, - syncedFolderProvider + syncedFolderProvider, + db ) } diff --git a/app/src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt b/app/src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt index dd0b241..01ad69c 100644 --- a/app/src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt +++ b/app/src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt @@ -1,6 +1,7 @@ /* * 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 */ @@ -12,6 +13,7 @@ import android.net.Uri import androidx.work.WorkerParameters import com.nextcloud.client.device.PowerManagementService import com.owncloud.android.datamodel.SyncedFolderProvider +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Ignore import org.junit.Test @@ -42,9 +44,9 @@ class ContentObserverWorkTest { @Before fun setUp() { - MockitoAnnotations.initMocks(this) + MockitoAnnotations.openMocks(this) worker = ContentObserverWork( - appContext = context, + context = context, params = params, syncedFolderProvider = folderProvider, powerManagementService = powerManagementService, @@ -56,70 +58,78 @@ class ContentObserverWorkTest { @Test fun job_reschedules_self_after_each_run_unconditionally() { - // GIVEN - // nothing to sync - whenever(params.triggeredContentUris).thenReturn(emptyList()) + runBlocking { + // GIVEN + // nothing to sync + whenever(params.triggeredContentUris).thenReturn(emptyList()) - // WHEN - // worker is called - worker.doWork() + // WHEN + // worker is called + worker.doWork() - // THEN - // worker reschedules itself unconditionally - verify(backgroundJobManager).scheduleContentObserverJob() + // THEN + // worker reschedules itself unconditionally + verify(backgroundJobManager).scheduleContentObserverJob() + } } @Test @Ignore("TODO: needs further refactoring") fun sync_is_triggered() { - // GIVEN - // power saving is disabled - // some folders are configured for syncing - whenever(powerManagementService.isPowerSavingEnabled).thenReturn(false) - whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(1) + runBlocking { + // GIVEN + // power saving is disabled + // some folders are configured for syncing + whenever(powerManagementService.isPowerSavingEnabled).thenReturn(false) + whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(1) - // WHEN - // worker is called - worker.doWork() + // WHEN + // worker is called + worker.doWork() - // THEN - // sync job is scheduled - // TO DO: verify(backgroundJobManager).sheduleFilesSync() or something like this + // THEN + // sync job is scheduled + // TO DO: verify(backgroundJobManager).sheduleFilesSync() or something like this + } } @Test @Ignore("TODO: needs further refactoring") fun sync_is_not_triggered_under_power_saving_mode() { - // GIVEN - // power saving is enabled - // some folders are configured for syncing - whenever(powerManagementService.isPowerSavingEnabled).thenReturn(true) - whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(1) + runBlocking { + // GIVEN + // power saving is enabled + // some folders are configured for syncing + whenever(powerManagementService.isPowerSavingEnabled).thenReturn(true) + whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(1) - // WHEN - // worker is called - worker.doWork() + // WHEN + // worker is called + worker.doWork() - // THEN - // sync job is scheduled - // TO DO: verify(backgroundJobManager, never()).sheduleFilesSync() or something like this) + // THEN + // sync job is scheduled + // TO DO: verify(backgroundJobManager, never()).sheduleFilesSync() or something like this) + } } @Test @Ignore("TODO: needs further refactoring") fun sync_is_not_triggered_if_no_folder_are_synced() { - // GIVEN - // power saving is disabled - // no folders configured for syncing - whenever(powerManagementService.isPowerSavingEnabled).thenReturn(false) - whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(0) + runBlocking { + // GIVEN + // power saving is disabled + // no folders configured for syncing + whenever(powerManagementService.isPowerSavingEnabled).thenReturn(false) + whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(0) - // WHEN - // worker is called - worker.doWork() + // WHEN + // worker is called + worker.doWork() - // THEN - // sync job is scheduled - // TO DO: verify(backgroundJobManager, never()).sheduleFilesSync() or something like this) + // THEN + // sync job is scheduled + // TO DO: verify(backgroundJobManager, never()).sheduleFilesSync() or something like this) + } } } diff --git a/app/src/test/java/com/nextcloud/client/utils/FileSortOrderBySizeTests.kt b/app/src/test/java/com/nextcloud/client/utils/FileSortOrderBySizeTests.kt new file mode 100644 index 0000000..9e3d240 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/utils/FileSortOrderBySizeTests.kt @@ -0,0 +1,165 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.utils + +import com.owncloud.android.utils.FileSortOrderBySize +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.junit.Before +import org.junit.After +import java.io.File + +@Suppress("TooManyFunctions", "MagicNumber") +class FileSortOrderBySizeTests { + private lateinit var tempDir: File + + @Before + fun setup() { + tempDir = File(System.getProperty("java.io.tmpdir"), "test_sort_${System.currentTimeMillis()}") + tempDir.mkdirs() + } + + @After + fun cleanup() { + tempDir.deleteRecursively() + } + + @Test + fun testSortAscendingWhenGivenFilesWithDifferentSizes() { + val smallFile = File(tempDir, "small.txt").apply { + writeText("x") + } + val mediumFile = File(tempDir, "medium.txt").apply { + writeText("x".repeat(100)) + } + val largeFile = File(tempDir, "large.txt").apply { + writeText("x".repeat(1000)) + } + + val files = mutableListOf(largeFile, smallFile, mediumFile) + val sortOrder = FileSortOrderBySize("test", true) + + val sorted = sortOrder.sortLocalFiles(files) + + assertEquals("small.txt", sorted[0].name) + assertEquals("medium.txt", sorted[1].name) + assertEquals("large.txt", sorted[2].name) + } + + @Test + fun testSortDescendingWhenGivenFilesWithDifferentSizes() { + val smallFile = File(tempDir, "small.txt").apply { + writeText("a") + } + val mediumFile = File(tempDir, "medium.txt").apply { + writeText("a".repeat(50)) + } + val largeFile = File(tempDir, "large.txt").apply { + writeText("a".repeat(500)) + } + + val files = mutableListOf(smallFile, largeFile, mediumFile) + val sortOrder = FileSortOrderBySize("test", false) + + val sorted = sortOrder.sortLocalFiles(files) + + assertEquals("large.txt", sorted[0].name) + assertEquals("medium.txt", sorted[1].name) + assertEquals("small.txt", sorted[2].name) + } + + @Test + fun testFoldersComesFirstWhenGivenMixedFilesAndFolders() { + val folder1 = File(tempDir, "folderA").apply { mkdirs() } + val folder2 = File(tempDir, "folderB").apply { mkdirs() } + val file1 = File(tempDir, "file1.txt").apply { writeText("content") } + val file2 = File(tempDir, "file2.txt").apply { writeText("data") } + + val files = mutableListOf(file1, folder1, file2, folder2) + val sortOrder = FileSortOrderBySize("test", true) + + val sorted = sortOrder.sortLocalFiles(files) + + assertTrue(sorted[0].isDirectory) + assertTrue(sorted[1].isDirectory) + assertFalse(sorted[2].isDirectory) + assertFalse(sorted[3].isDirectory) + } + + @Test + fun testSortByFolderSizeWhenGivenFoldersWithDifferentContent() { + val smallFolder = File(tempDir, "smallFolder").apply { + mkdirs() + File(this, "file.txt").writeText("x") + } + + val largeFolder = File(tempDir, "largeFolder").apply { + mkdirs() + File(this, "file1.txt").writeText("x".repeat(100)) + File(this, "file2.txt").writeText("x".repeat(100)) + } + + val files = mutableListOf(largeFolder, smallFolder) + val sortOrder = FileSortOrderBySize("test", true) + + val sorted = sortOrder.sortLocalFiles(files) + + assertEquals("smallFolder", sorted[0].name) + assertEquals("largeFolder", sorted[1].name) + } + + @Test + fun testEmptyListWhenGivenNoFiles() { + val files = mutableListOf() + val sortOrder = FileSortOrderBySize("test", true) + + val sorted = sortOrder.sortLocalFiles(files) + + assertTrue(sorted.isEmpty()) + } + + @Test + fun testSingleFileWhenGivenOnlyOneFile() { + val file = File(tempDir, "single.txt").apply { + writeText("content") + } + + val files = mutableListOf(file) + val sortOrder = FileSortOrderBySize("test", true) + + val sorted = sortOrder.sortLocalFiles(files) + + assertEquals(1, sorted.size) + assertEquals("single.txt", sorted[0].name) + } + + @Test + fun testSameOrderWhenGivenFilesWithSameSize() { + val file1 = File(tempDir, "file1.txt").apply { + writeText("same") + } + val file2 = File(tempDir, "file2.txt").apply { + writeText("same") + } + val file3 = File(tempDir, "file3.txt").apply { + writeText("same") + } + + val files = mutableListOf(file1, file2, file3) + val sortOrder = FileSortOrderBySize("test", true) + + val sorted = sortOrder.sortLocalFiles(files) + + assertEquals(3, sorted.size) + + // All files have same size, so order should be stable + sorted.forEach { assertTrue(it.length() == 4L) } + } +} diff --git a/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt b/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt index f014510..56441d2 100644 --- a/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt +++ b/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt @@ -304,4 +304,22 @@ class FileStorageUtilsTest { val result = FileStorageUtils.containsBidiControlCharacters("/Foo%e2%80%aedm.exe") assertTrue(result) } + + @Test + fun testContainsBidiControlCharactersWhenGivenMalformedEncodedSequenceShouldNotThrowAndReturnFalse() { + val result = FileStorageUtils.containsBidiControlCharacters("file%") + assertFalse(result) + } + + @Test + fun testContainsBidiControlCharactersWhenGivenBrokenUrlEncodedPatternShouldHandleGracefully() { + val result = FileStorageUtils.containsBidiControlCharacters("file%2") + assertFalse(result) + } + + @Test + fun testContainsBidiControlCharactersWhenGivenMultipleBidiCharactersShouldReturnTrue() { + val result = FileStorageUtils.containsBidiControlCharacters("safe\u202Ebad\u202Bname.txt") + assertTrue(result) + } } diff --git a/app/src/test/java/com/nextcloud/client/utils/FolderSizeCalculationTests.kt b/app/src/test/java/com/nextcloud/client/utils/FolderSizeCalculationTests.kt new file mode 100644 index 0000000..788dcfa --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/utils/FolderSizeCalculationTests.kt @@ -0,0 +1,196 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.utils + +import com.owncloud.android.utils.FileStorageUtils +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.File + +@Suppress("TooManyFunctions", "MagicNumber") +class FolderSizeCalculationTests { + + private lateinit var testDir: File + + @Before + fun setUp() { + testDir = File(System.getProperty("java.io.tmpdir"), "test_folder_${System.currentTimeMillis()}") + testDir.mkdirs() + } + + @After + fun tearDown() { + testDir.deleteRecursively() + } + + @Test + fun testReturnZeroWhenGivenNullDirectory() { + val result = FileStorageUtils.getFolderSize(null) + assertEquals(0L, result) + } + + @Test + fun testReturnZeroWhenGivenNonExistentDirectory() { + val nonExistent = File(testDir, "does_not_exist") + val result = FileStorageUtils.getFolderSize(nonExistent) + assertEquals(0L, result) + } + + @Test + fun testReturnZeroWhenGivenRegularFile() { + val file = File(testDir, "regular_file.txt") + file.writeText("content") + + val result = FileStorageUtils.getFolderSize(file) + assertEquals(0L, result) + } + + @Test + fun testReturnZeroWhenGivenEmptyDirectory() { + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(0L, result) + } + + @Test + fun testReturnCorrectSizeForSingleFile() { + val file = File(testDir, "file.txt") + file.writeText("12345") // 5 bytes + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(5L, result) + } + + @Test + fun testReturnCorrectSizeForMultipleFiles() { + File(testDir, "file1.txt").writeText("123") // 3 bytes + File(testDir, "file2.txt").writeText("12345") // 5 bytes + File(testDir, "file3.txt").writeText("1234567") // 7 bytes + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(15L, result) + } + + @Test + fun testReturnCorrectSizeWithSubdirectory() { + File(testDir, "file1.txt").writeText("123") // 3 bytes + + val subDir = File(testDir, "subdir") + subDir.mkdirs() + File(subDir, "file2.txt").writeText("12345") // 5 bytes + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(8L, result) + } + + @Test + fun testReturnCorrectSizeWithNestedSubdirectories() { + File(testDir, "file1.txt").writeText("12") // 2 bytes + + val subDir1 = File(testDir, "subdir1") + subDir1.mkdirs() + File(subDir1, "file2.txt").writeText("123") // 3 bytes + + val subDir2 = File(subDir1, "subdir2") + subDir2.mkdirs() + File(subDir2, "file3.txt").writeText("1234") // 4 bytes + + val subDir3 = File(subDir2, "subdir3") + subDir3.mkdirs() + File(subDir3, "file4.txt").writeText("12345") // 5 bytes + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(14L, result) // 2 + 3 + 4 + 5 + } + + @Test + fun testReturnCorrectSizeWithEmptySubdirectories() { + File(testDir, "file1.txt").writeText("123") // 3 bytes + + val emptyDir1 = File(testDir, "empty1") + emptyDir1.mkdirs() + + val emptyDir2 = File(testDir, "empty2") + emptyDir2.mkdirs() + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(3L, result) + } + + @Test + fun testReturnCorrectSizeWithLargeFile() { + val file = File(testDir, "large_file.txt") + val content = "x".repeat(10000) // 10000 bytes + file.writeText(content) + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(10000L, result) + } + + @Test + fun testReturnCorrectSizeWithMixedFilesAndDirectories() { + // Root level files + File(testDir, "root1.txt").writeText("12") // 2 bytes + File(testDir, "root2.txt").writeText("123") // 3 bytes + + // First subdirectory + val subDir1 = File(testDir, "sub1") + subDir1.mkdirs() + File(subDir1, "sub1_file1.txt").writeText("1234") // 4 bytes + File(subDir1, "sub1_file2.txt").writeText("12345") // 5 bytes + + // Second subdirectory + val subDir2 = File(testDir, "sub2") + subDir2.mkdirs() + File(subDir2, "sub2_file.txt").writeText("123456") // 6 bytes + + // Nested subdirectory + val nestedDir = File(subDir1, "nested") + nestedDir.mkdirs() + File(nestedDir, "nested_file.txt").writeText("1234567") // 7 bytes + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(27L, result) // 2 + 3 + 4 + 5 + 6 + 7 + } + + @Test + fun testReturnZeroForDirectoryWithOnlyEmptySubdirectories() { + val sub1 = File(testDir, "sub1") + sub1.mkdirs() + + val sub2 = File(testDir, "sub2") + sub2.mkdirs() + + val nested = File(sub1, "nested") + nested.mkdirs() + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(0L, result) + } + + @Test + fun testReturnCorrectSizeWithSpecialCharactersInFilenames() { + File(testDir, "file with spaces.txt").writeText("12") // 2 bytes + File(testDir, "file-with-dashes.txt").writeText("123") // 3 bytes + File(testDir, "file_with_underscores.txt").writeText("1234") // 4 bytes + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(9L, result) + } + + @Test + fun testReturnCorrectSizeWithEmptyFiles() { + File(testDir, "empty1.txt").writeText("") // 0 bytes + File(testDir, "empty2.txt").writeText("") // 0 bytes + File(testDir, "nonempty.txt").writeText("123") // 3 bytes + + val result = FileStorageUtils.getFolderSize(testDir) + assertEquals(3L, result) + } +} diff --git a/app/src/test/java/com/nextcloud/client/utils/OCFileSortTest.kt b/app/src/test/java/com/nextcloud/client/utils/OCFileSortTest.kt index da8eaaa..4a37404 100644 --- a/app/src/test/java/com/nextcloud/client/utils/OCFileSortTest.kt +++ b/app/src/test/java/com/nextcloud/client/utils/OCFileSortTest.kt @@ -33,7 +33,7 @@ class OCFileSortTest { fun testFileSortOrder() { val toSort = getShuffledList() - FileSortOrder.SORT_A_TO_Z.sortCloudFiles(toSort) + FileSortOrder.SORT_A_TO_Z.sortCloudFiles(toSort, true, true) verifySort(toSort) } diff --git a/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt b/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt new file mode 100644 index 0000000..7148892 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt @@ -0,0 +1,319 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.utils + +import com.nextcloud.client.jobs.autoUpload.AutoUploadHelper +import com.nextcloud.client.preferences.SubFolderRule +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.FileTime + +class AutoUploadHelperTest { + + private lateinit var tempDir: File + private val helper = AutoUploadHelper() + private val accountName = "testAccount" + + @Before + fun setup() { + tempDir = Files.createTempDirectory("auto_upload_test_").toFile() + tempDir.mkdirs() + assertTrue("Failed to create temp directory", tempDir.exists()) + } + + @After + fun cleanup() { + tempDir.deleteRecursively() + } + + private fun createTestFolder( + localPath: String = tempDir.absolutePath, + excludeHidden: Boolean = false, + lastScan: Long = -1L, + enabledTimestamp: Long = 0L, + alsoUploadExistingFiles: Boolean = true, + type: MediaFolderType = MediaFolderType.CUSTOM + ): SyncedFolder = SyncedFolder( + localPath, + "", + true, + false, + alsoUploadExistingFiles, + false, + accountName, + 1, + 1, + true, + enabledTimestamp, + type, + false, + SubFolderRule.YEAR_MONTH, + excludeHidden, + lastScan + ) + + @Test + fun testInsertCustomFolderProcessedCount() { + File(tempDir, "file1.txt").apply { + writeText("Hello") + assertTrue("File1 should exist", exists()) + } + File(tempDir, "file2.txt").apply { + writeText("World") + assertTrue("File2 should exist", exists()) + } + + val folder = createTestFolder( + localPath = tempDir.absolutePath, + type = MediaFolderType.CUSTOM + ) + + val processedCount = helper.insertCustomFolderIntoDB(folder, null) + + assertEquals("Should process 2 files", 2, processedCount) + } + + @Test + fun testInsertCustomFolderWithHiddenFiles() { + File(tempDir, "visible.txt").apply { writeText("Visible") } + File(tempDir, ".hidden.txt").apply { + writeText("Hidden") + } + + val folder = createTestFolder( + excludeHidden = true, + type = MediaFolderType.CUSTOM + ) + + val processedCount = helper.insertCustomFolderIntoDB(folder, null) + + assertTrue("Should process at least 1 file", processedCount >= 1) + } + + @Test + fun testInsertCustomFolderWithLastScanFilter() { + val currentTime = System.currentTimeMillis() + + // Create an old file + val oldFile = File(tempDir, "old.txt").apply { writeText("Old") } + oldFile.setLastModified(currentTime - 10000) // 10 seconds ago + + // Create a new file + val newFile = File(tempDir, "new.txt").apply { writeText("New") } + newFile.setLastModified(currentTime) + + val folder = createTestFolder( + lastScan = currentTime - 5000, // Last scan was 5 seconds ago + type = MediaFolderType.CUSTOM + ) + + val processedCount = helper.insertCustomFolderIntoDB(folder, null) + + // Should only process the new file (modified after last scan) + assertEquals("Should process only 1 new file", 1, processedCount) + } + + @Test + fun testInsertCustomFolderNotExisting() { + val currentTime = System.currentTimeMillis() + + // old file should not be scanned + val oldFile = File(tempDir, "old.txt").apply { writeText("Old") } + oldFile.setLastModified(currentTime - 10000) + + val newFile = File(tempDir, "new.txt").apply { writeText("New") } + newFile.setLastModified(currentTime) + + // Enabled 5 seconds ago + val folder = createTestFolder( + enabledTimestamp = currentTime - 5000, + type = MediaFolderType.CUSTOM + ).apply { + lastScanTimestampMs = currentTime + } + + val processedCount = helper.insertCustomFolderIntoDB(folder, null) + + // Should only process files newer than enabledTimestamp + assertEquals("Should process only files after enabled timestamp", 1, processedCount) + } + + @Test + fun testInsertCustomFolderEmpty() { + val folder = createTestFolder(type = MediaFolderType.CUSTOM) + val processedCount = helper.insertCustomFolderIntoDB(folder, null) + + assertEquals("Empty folder should process 0 files", 0, processedCount) + } + + @Test + fun testInsertCustomFolderNonExistentPath() { + val nonExistentPath = File(tempDir, "does_not_exist").absolutePath + val folder = createTestFolder( + localPath = nonExistentPath, + type = MediaFolderType.CUSTOM + ) + + val processedCount = helper.insertCustomFolderIntoDB(folder, null) + + assertEquals("Non-existent folder should return 0", 0, processedCount) + } + + @Test + fun testInsertCustomFolderWithSubdirectories() { + val subDir = File(tempDir, "subdir") + subDir.mkdirs() + + File(tempDir, "root.txt").writeText("Root file") + File(subDir, "nested.txt").writeText("Nested file") + + val folder = createTestFolder(type = MediaFolderType.CUSTOM) + val processedCount = helper.insertCustomFolderIntoDB(folder, null) + + assertEquals("Should process files in root and subdirectories", 2, processedCount) + } + + @Test + fun testInsertCustomFolderWithHiddenDirectory() { + val currentTime = System.currentTimeMillis() + + val hiddenDir = File(tempDir, ".hidden_dir") + hiddenDir.mkdirs() + File(hiddenDir, "file.txt").writeText("Hidden dir file") + + // Create regular file + File(tempDir, "regular.txt").writeText("Regular file") + + val folder = createTestFolder( + excludeHidden = true, + type = MediaFolderType.CUSTOM + ).apply { + lastScanTimestampMs = currentTime + } + + val processedCount = helper.insertCustomFolderIntoDB(folder, null) + + // Should skip hidden directory and its contents + assertEquals("Should only process regular file", 1, processedCount) + } + + @Test + fun testInsertCustomFolderComplexNestedStructure() { + // Root folder: FOLDER_A + val folderA = File(tempDir, "FOLDER_A") + folderA.mkdirs() + + // Subfolders of FOLDER_A + val folderB = File(folderA, "FOLDER_B") + folderB.mkdirs() + val folderC = File(folderA, "FOLDER_C") + folderC.mkdirs() + + // Files in FOLDER_A + File(folderA, "FILE_A.txt").writeText("File in A") + + // Subfolders of FOLDER_B + val folderD = File(folderB, "FOLDER_D") + folderD.mkdirs() + + // Files in FOLDER_B + File(folderB, "FILE_B.txt").writeText("File in B") + + // Files in FOLDER_C + File(folderC, "FILE_A.txt").writeText("File in C") + File(folderC, "FILE_B.txt").writeText("Another file in C") + + // Subfolders of FOLDER_D + val folderE = File(folderD, "FOLDER_E") + folderE.mkdirs() + + // Files in FOLDER_E + File(folderE, "FILE_A.txt").writeText("File in E") + + val syncedFolder = createTestFolder( + localPath = folderA.absolutePath, + type = MediaFolderType.CUSTOM + ) + + /* + * Expected file count with full paths: + * ${tempDir.absolutePath}/FOLDER_A/FILE_A.txt -> 1 + * ${tempDir.absolutePath}/FOLDER_A/FOLDER_B/FILE_B.txt -> 1 + * ${tempDir.absolutePath}/FOLDER_A/FOLDER_C/FILE_A.txt -> 1 + * ${tempDir.absolutePath}/FOLDER_A/FOLDER_C/FILE_B.txt -> 1 + * ${tempDir.absolutePath}/FOLDER_A/FOLDER_B/FOLDER_D/FOLDER_E/FILE_A.txt -> 1 + * Total = 5 files + */ + val processedCount = helper.insertCustomFolderIntoDB(syncedFolder, null) + assertEquals("Should process all files in complex nested structure", 5, processedCount) + } + + @Test + fun testAlsoUploadExistingFiles() { + val currentTime = System.currentTimeMillis() + + // Old file (created before enabling auto-upload) + val oldFile = File(tempDir, "old_file.txt").apply { + writeText("Old file") + } + val oldFilePath = oldFile.toPath() + Files.setAttribute( + oldFilePath, + "creationTime", + FileTime.fromMillis(currentTime - 60_000) // 1 minute before enabling + ) + + // New file (created after enabling auto-upload) + val newFile = File(tempDir, "new_file.txt").apply { + writeText("New file") + } + val newFilePath = newFile.toPath() + Files.setAttribute( + newFilePath, + "creationTime", + FileTime.fromMillis(currentTime + 1_000) // 1 second after enabling + ) + + val folderSkipOld = createTestFolder( + localPath = tempDir.absolutePath, + type = MediaFolderType.CUSTOM, + alsoUploadExistingFiles = false + ).apply { + setEnabled(true, currentTime) + } + + val processedCountSkipOld = helper.insertCustomFolderIntoDB(folderSkipOld, null) + assertEquals( + "When 'also upload existing' is disabled, only new files created after enabling should be processed", + 1, + processedCountSkipOld + ) + + val folderUploadAll = createTestFolder( + localPath = tempDir.absolutePath, + type = MediaFolderType.CUSTOM, + alsoUploadExistingFiles = true + ).apply { + setEnabled(true, currentTime) + } + + val processedCountAll = helper.insertCustomFolderIntoDB(folderUploadAll, null) + assertEquals( + "When 'also upload existing' is enabled, should upload all files", + 2, + processedCountAll + ) + } +} diff --git a/appscan/build.gradle.kts b/appscan/build.gradle.kts new file mode 100644 index 0000000..5311613 --- /dev/null +++ b/appscan/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Jimly Asshiddiqy + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) +} + +android { + namespace = "com.nextcloud.appscan" + + defaultConfig { + minSdk = 27 + compileSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + lint.targetSdk = 36 + testOptions.targetSdk = 36 +} + +kotlin.compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") +} + +dependencies { + implementation(libs.appcompat) + implementation(libs.document.scanning.android.sdk) + implementation(libs.ui) +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..75f16a6 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Jimly Asshiddiqy + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.spotless) apply false + alias(libs.plugins.kapt) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.spotbugs) apply false + alias(libs.plugins.detekt) apply false + // needed to make renovate run without shot, as shot requires Android SDK + // https://github.com/pedrovgs/Shot/issues/300 + alias(libs.plugins.shot) apply false +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} + +tasks.register("installGitHooks") { + description = "Install git hooks" + + val sourceFolder = "${rootProject.projectDir}/scripts/hooks" + val destFolder = "${rootProject.projectDir}/.git/hooks" + + from(sourceFolder) { include("*") } + into(destFolder) + eachFile { println("${sourceFolder}/${file.path} -> ${destFolder}/${file.path}") } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8e76554..eb0f2de 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -210,7 +210,7 @@ end desc "compute version" private_lane :androidVersion do - File.open("../app/build.gradle","r") do |f| + File.open("../app/build.gradle.kts","r") do |f| text = f.read # everything between Document and Authors diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c5ed14..bcbefe2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,82 +2,92 @@ # SPDX-License-Identifier: AGPL-3.0-or-later [versions] -androidCommonLibraryVersion = "0.28.0" +androidCommonLibraryVersion = "0.29.0" androidGifDrawableVersion = "1.2.29" androidImageCropperVersion = "4.6.0" +androidLibraryVersion = "8c77f600ac942f6eb4e3c4447143fc949d34acc6" +androidPluginVersion = '8.13.1' androidsvgVersion = "1.4" +androidxMediaVersion = "1.5.1" androidxTestVersion = "1.7.0" -annotationsVersion = "3.0.1u2" annotationVersion = "1.9.1" +annotationsVersion = "3.0.1u2" appCompatVersion = "1.7.1" bcpkixJdk18onVersion = "1.81" cardviewVersion = "1.0.0" +checker = "3.21.2" coilVersion = "2.7.0" commonsHttpclient = "3.1" commonsIoVersion = "2.20.0" +composeBom = "2025.10.01" conscryptAndroidVersion = "2.5.3" constraintlayoutVersion = "2.2.1" coreTestingVersion = "2.2.0" coreVersion = "0.15.0" -daggerVersion = "2.57.1" +daggerVersion = "2.57.2" +detektGradlePlugin = "1.23.8" dexopenerVersion = "2.0.5" disklrucacheVersion = "2.0.2" +documentScannerVersion = "1.2.3" emojiGoogleVersion = "0.21.0" espressoVersion = "3.7.0" eventbusVersion = "3.3.1" exifinterfaceVersion = "1.4.1" ezVcardVersion = "0.12.1" -fbContribVersion = "7.6.14" +fbContribVersion = "7.6.15" findsecbugsPluginVersion = "1.14.0" -firebaseMessagingVersion = "25.0.0" +firebaseMessagingVersion = "25.0.1" flexboxVersion = "3.0.0" fragmentKtxVersion = "1.8.9" -glide = "5.0.4" -gsonVersion = "2.13.1" +glide = "5.0.5" +gsonVersion = "2.13.2" ical4jVersion = "3.2.19" jackrabbitWebdavVersion = "2.13.5" +jacoco = "0.8.14" jsonVersion = "20250517" junit = "4.13.2" junitVersion = "1.3.0" juniversalchardetVersion = "2.5.0" +kotlin = "2.2.21" +kotlinxSerializationJson = "1.9.0" +ksp = "2.3.1" +leakcanary = "2.14" legacySupportV4Version = "1.0.0" libraryVersion = "1.3.0" -lifecycleViewmodelKtxVersion = "2.9.3" +lifecycleViewmodelKtxVersion = "2.9.4" loaderviewlibraryVersion = "3.0.0" markwonVersion = "4.6.2" +materialIconsCoreVersion = "1.7.8" materialVersion = "1.13.0" media3 = "1.8.0" mockitoKotlinVersion = "4.1.0" mockitoVersion = "4.11.0" -mockkVersion = "1.14.5" +mockkVersion = "1.14.6" nnioVersion = "0.3.1" +objenesis = "3.4" orchestratorVersion = "1.6.1" orgJbundleUtilOsgiWrappedOrgApacheHttpClientVersion = "4.1.2" osmdroidAndroidVersion = "6.1.20" photoviewVersion = "2.3.0" -playServicesBaseVersion = "18.7.2" +playServicesBaseVersion = "18.9.0" prismVersion = "2.0.0" qrcodescannerVersion = "0.1.2.4" reviewKtxVersion = "2.0.2" -roomVersion = "2.7.2" +roomVersion = "2.8.3" screengrabVersion = "2.1.1" sectionedRecyclerviewVersion = "0.6.1" shotVersion = "6.1.0" +slfj = "1.7.36" splash-screen = "1.0.1" -composeBom = "2025.08.01" -spotbugsGradlePlugin = "6.3.0" -detektGradlePlugin = "1.23.8" +spotbugsGradlePlugin = "6.4.5" spotless = "7.2.1" stateless4jVersion = "2.6.0" webkitVersion = "1.14.0" -workRuntime = "2.10.3" - -kotlin = "2.2.20" -ksp = "2.2.20-2.0.2" +workRuntime = "2.10.4" [libraries] - # Crypto +android-library = { module = "com.github.nextcloud:android-library", version.ref = "androidLibraryVersion" } conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscryptAndroidVersion" } bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcpkixJdk18onVersion" } @@ -85,8 +95,10 @@ bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bc appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appCompatVersion" } cardview = { module = "androidx.cardview:cardview", version.ref = "cardviewVersion" } core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTestVersion" } +document-scanning-android-sdk = { module = "com.github.Hazzatur:Document-Scanning-Android-SDK", version.ref = "documentScannerVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtxVersion" } exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterfaceVersion" } +material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCoreVersion" } webkit = { module = "androidx.webkit:webkit", version.ref = "webkitVersion" } splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splash-screen" } sectioned-recyclerview = { module = "com.github.nextcloud-deps:sectioned-recyclerview", version.ref = "sectionedRecyclerviewVersion" } @@ -102,6 +114,8 @@ coil = { module = "io.coil-kt:coil", version.ref = "coilVersion" } constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayoutVersion" } emoji-google = { module = "com.vanniktech:emoji-google", version.ref = "emojiGoogleVersion" } +# kotlinx +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } # Other annotation = { module = "androidx.annotation:annotation", version.ref = "annotationVersion" } @@ -126,20 +140,26 @@ jackrabbit-webdav = { module = "org.apache.jackrabbit:jackrabbit-webdav", versio json = { module = "org.json:json", version.ref = "jsonVersion" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardetVersion" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" } nnio = { module = "org.lukhnos:nnio", version.ref = "nnioVersion" } org-jbundle-util-osgi-wrapped-org-apache-http-client = { module = "org.jbundle.util.osgi.wrapped:org.jbundle.util.osgi.wrapped.org.apache.http.client", version.ref = "orgJbundleUtilOsgiWrappedOrgApacheHttpClientVersion" } osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroidAndroidVersion" } +objenesis = { module = "org.objenesis:objenesis", version.ref = "objenesis" } play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "playServicesBaseVersion" } review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "reviewKtxVersion" } +slfj = { module = "org.slf4j:jcl-over-slf4j", version.ref = "slfj" } # Mockito mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoVersion" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoVersion" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlinVersion" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockkVersion" } +mockk = { module = "io.mockk:mockk", version.ref = "mockkVersion" } # Dagger dagger = { module = "com.google.dagger:dagger", version.ref = "daggerVersion" } +dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "daggerVersion" } +dagger-processor = { module = "com.google.dagger:dagger-android-processor", version.ref = "daggerVersion" } dagger-android = { module = "com.google.dagger:dagger-android", version.ref = "daggerVersion" } dagger-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "daggerVersion" } @@ -170,6 +190,7 @@ media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } # Room room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } room-testing = { module = "androidx.room:room-testing", version.ref = "roomVersion" } # Espresso @@ -182,6 +203,7 @@ espresso-web = { module = "androidx.test.espresso:espresso-web", version.ref = " # Test junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } + core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTestingVersion" } orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestratorVersion" } rules = { module = "androidx.test:rules", version.ref = "androidxTestVersion" } @@ -205,6 +227,7 @@ stateless4j = { module = "com.github.stateless4j:stateless4j", version.ref = "st syntax-highlight = { module = "io.noties.markwon:syntax-highlight", version.ref = "markwonVersion" } core = { module = "io.noties.markwon:core", version.ref = "markwonVersion" } prism4j = { module = "io.noties:prism4j", version.ref = "prismVersion" } +prism4j-bundler = { module = "io.noties:prism4j-bundler", version.ref = "prismVersion" } # Nextcloud libraries ui = { module = "com.github.nextcloud.android-common:ui", version.ref = "androidCommonLibraryVersion" } @@ -214,8 +237,55 @@ qrcodescanner = { module = "com.github.nextcloud-deps:qrcodescanner", version.re work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" } +[bundles] +media3 = ["media3-ui", "media3-session", "media3-exoplayer", "media3-datasource"] +espresso = ["espresso-core", "espresso-contrib", "espresso-web", "espresso-accessibility", "espresso-intents", "espresso-idling-resource"] +ui = ["appcompat", "webkit", "cardview", "exifinterface", "fragment-ktx"] +markdown-rendering = [ + "core", + "ext-strikethrough", + "ext-tables", + "ext-tasklist", + "html", + "syntax-highlight", + "prism4j" +] +unit-test = [ + "junit-junit", + "test-core", + "json", + "mockito-kotlin", + "mockk", + "mockk-android", + "mockito-core", + "mockito-android", + "core-testing" +] +mocking = [ + "dexopener", # required to allow mocking on API 27 and older + "mockito-kotlin", + "mockk", + "mockk-android", + "mockito-core", + "mockito-android", + "screenshot-core" +] +gplay = [ + "firebase-messaging", + "play-services-base", + "review-ktx" +] + [plugins] +android-application = { id = "com.android.application", version.ref = "androidPluginVersion" } +android-library = { id = "com.android.library", version.ref = "androidPluginVersion" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +spotbugs = { id = "com.github.spotbugs", version.ref = "spotbugsGradlePlugin" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detektGradlePlugin" } +shot = { id = "shot", version.ref = "shotVersion" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index ea57934..3280ec1 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1,4 +1,10 @@ + true @@ -42,6 +48,7 @@ + @@ -369,6 +376,7 @@ + @@ -840,6 +848,14 @@ + + + + + + + + @@ -1169,6 +1185,11 @@ + + + + + @@ -1355,6 +1376,21 @@ + + + + + + + + + + + + + + + @@ -1403,6 +1439,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1451,6 +1511,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1507,6 +1591,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1555,6 +1663,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1603,6 +1735,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1651,6 +1807,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1699,6 +1879,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1747,6 +1951,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1777,6 +2005,21 @@ + + + + + + + + + + + + + + + @@ -1825,6 +2068,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1921,6 +2188,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1969,6 +2260,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1985,6 +2300,14 @@ + + + + + + + + @@ -2001,6 +2324,14 @@ + + + + + + + + @@ -2075,6 +2406,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2123,6 +2478,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2131,6 +2510,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2139,6 +2542,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2208,6 +2635,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2256,6 +2707,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2320,6 +2795,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2368,6 +2867,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2432,6 +2955,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2480,6 +3027,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2544,6 +3115,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2592,6 +3187,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2688,6 +3307,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2736,6 +3379,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2766,6 +3433,21 @@ + + + + + + + + + + + + + + + @@ -2814,6 +3496,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2844,6 +3550,21 @@ + + + + + + + + + + + + + + + @@ -2892,6 +3613,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2940,6 +3685,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2988,6 +3757,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3052,6 +3845,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3100,6 +3917,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3164,6 +4005,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3212,6 +4077,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3401,6 +4290,14 @@ + + + + + + + + @@ -3445,7 +4342,7 @@ - + @@ -3499,6 +4396,14 @@ + + + + + + + + @@ -4439,6 +5344,11 @@ + + + + + @@ -4447,11 +5357,24 @@ + + + + + + + + + + + + + @@ -4460,11 +5383,24 @@ + + + + + + + + + + + + + @@ -4473,11 +5409,24 @@ + + + + + + + + + + + + + @@ -4486,11 +5435,32 @@ + + + + + + + + + + + + + + + + + + + + + @@ -4507,6 +5477,14 @@ + + + + + + + + @@ -4515,6 +5493,14 @@ + + + + + + + + @@ -5039,6 +6025,14 @@ + + + + + + + + @@ -5100,6 +6094,14 @@ + + + + + + + + @@ -5148,6 +6150,14 @@ + + + + + + + + @@ -5253,6 +6263,14 @@ + + + + + + + + @@ -5346,6 +6364,14 @@ + + + + + + + + @@ -5415,6 +6441,14 @@ + + + + + + + + @@ -5487,6 +6521,14 @@ + + + + + + + + @@ -5584,6 +6626,14 @@ + + + + + + + + @@ -5632,6 +6682,14 @@ + + + + + + + + @@ -5671,6 +6729,14 @@ + + + + + + + + @@ -5719,6 +6785,14 @@ + + + + + + + + @@ -5782,6 +6856,14 @@ + + + + + + + + @@ -5830,6 +6912,14 @@ + + + + + + + + @@ -5870,6 +6960,14 @@ + + + + + + + + @@ -5985,6 +7083,17 @@ + + + + + + + + + + + @@ -6033,6 +7142,14 @@ + + + + + + + + @@ -6102,6 +7219,14 @@ + + + + + + + + @@ -6193,6 +7318,17 @@ + + + + + + + + + + + @@ -6225,6 +7361,14 @@ + + + + + + + + @@ -7102,6 +8246,22 @@ + + + + + + + + + + + + + + + + @@ -7118,6 +8278,22 @@ + + + + + + + + + + + + + + + + @@ -7150,6 +8326,22 @@ + + + + + + + + + + + + + + + + @@ -7182,6 +8374,22 @@ + + + + + + + + + + + + + + + + @@ -7198,6 +8406,22 @@ + + + + + + + + + + + + + + + + @@ -7227,6 +8451,22 @@ + + + + + + + + + + + + + + + + @@ -7259,6 +8499,22 @@ + + + + + + + + + + + + + + + + @@ -7275,6 +8531,22 @@ + + + + + + + + + + + + + + + + @@ -7307,6 +8579,22 @@ + + + + + + + + + + + + + + + + @@ -7323,6 +8611,22 @@ + + + + + + + + + + + + + + + + @@ -7355,6 +8659,22 @@ + + + + + + + + + + + + + + + + @@ -7371,6 +8691,22 @@ + + + + + + + + + + + + + + + + @@ -7419,6 +8755,14 @@ + + + + + + + + @@ -7435,6 +8779,14 @@ + + + + + + + + @@ -7448,6 +8800,14 @@ + + + + + + + + @@ -7464,6 +8824,14 @@ + + + + + + + + @@ -7496,6 +8864,14 @@ + + + + + + + + @@ -7544,6 +8920,22 @@ + + + + + + + + + + + + + + + + @@ -7560,6 +8952,22 @@ + + + + + + + + + + + + + + + + @@ -7597,6 +9005,22 @@ + + + + + + + + + + + + + + + + @@ -7613,6 +9037,22 @@ + + + + + + + + + + + + + + + + @@ -8342,6 +9782,14 @@ + + + + + + + + @@ -8390,6 +9838,14 @@ + + + + + + + + @@ -8712,6 +10168,11 @@ + + + + + @@ -8837,6 +10298,11 @@ + + + + + @@ -9202,6 +10668,14 @@ + + + + + + + + @@ -10115,6 +11589,9 @@ + + + @@ -11822,6 +13299,14 @@ + + + + + + + + @@ -11870,6 +13355,14 @@ + + + + + + + + @@ -11918,6 +13411,14 @@ + + + + + + + + @@ -12086,6 +13587,14 @@ + + + + + + + + @@ -12134,6 +13643,14 @@ + + + + + + + + @@ -12182,6 +13699,14 @@ + + + + + + + + @@ -12230,6 +13755,14 @@ + + + + + + + + @@ -15429,6 +16962,21 @@ + + + + + + + + + + + + + + + @@ -15469,6 +17017,11 @@ + + + + + @@ -15504,6 +17057,11 @@ + + + + + @@ -15787,6 +17345,14 @@ + + + + + + + + @@ -15803,6 +17369,14 @@ + + + + + + + + @@ -15819,6 +17393,14 @@ + + + + + + + + @@ -15843,6 +17425,14 @@ + + + + + + + + @@ -15859,6 +17449,14 @@ + + + + + + + + @@ -15899,6 +17497,14 @@ + + + + + + + + @@ -15947,6 +17553,14 @@ + + + + + + + + @@ -15963,14 +17577,6 @@ - - - - - - - - @@ -16011,6 +17617,14 @@ + + + + + + + + @@ -16027,6 +17641,14 @@ + + + + + + + + @@ -16035,6 +17657,14 @@ + + + + + + + + @@ -16171,6 +17801,22 @@ + + + + + + + + + + + + + + + + @@ -16235,6 +17881,14 @@ + + + + + + + + @@ -16331,6 +17985,14 @@ + + + + + + + + @@ -16363,6 +18025,14 @@ + + + + + + + + @@ -16491,6 +18161,14 @@ + + + + + + + + @@ -16523,6 +18201,19 @@ + + + + + + + + + + + + + @@ -16531,6 +18222,14 @@ + + + + + + + + @@ -16707,14 +18406,6 @@ - - - - - - - - @@ -16867,6 +18558,14 @@ + + + + + + + + @@ -16999,6 +18698,14 @@ + + + + + + + + @@ -17127,6 +18834,14 @@ + + + + + + + + @@ -17159,6 +18874,21 @@ + + + + + + + + + + + + + + + @@ -17199,6 +18929,22 @@ + + + + + + + + + + + + + + + + @@ -17239,6 +18985,22 @@ + + + + + + + + + + + + + + + + @@ -17359,6 +19121,22 @@ + + + + + + + + + + + + + + + + @@ -17518,6 +19296,22 @@ + + + + + + + + + + + + + + + + @@ -17550,6 +19344,22 @@ + + + + + + + + + + + + + + + + @@ -17619,6 +19429,14 @@ + + + + + + + + @@ -18112,6 +19930,14 @@ + + + + + + + + @@ -18176,6 +20002,14 @@ + + + + + + + + @@ -18240,6 +20074,14 @@ + + + + + + + + @@ -18304,6 +20146,14 @@ + + + + + + + + @@ -18368,6 +20218,14 @@ + + + + + + + + @@ -18432,6 +20290,14 @@ + + + + + + + + @@ -18504,6 +20370,14 @@ + + + + + + + + @@ -18564,6 +20438,21 @@ + + + + + + + + + + + + + + + @@ -18652,6 +20541,24 @@ + + + + + + + + + + + + + + + + + + @@ -18684,6 +20591,22 @@ + + + + + + + + + + + + + + + + @@ -18833,6 +20756,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -18921,6 +20868,14 @@ + + + + + + + + @@ -19001,6 +20956,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -19089,6 +21068,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -19218,6 +21221,14 @@ + + + + + + + + @@ -19329,6 +21340,11 @@ + + + + + @@ -19357,6 +21373,14 @@ + + + + + + + + @@ -19410,6 +21434,14 @@ + + + + + + + + @@ -19618,6 +21650,14 @@ + + + + + + + + @@ -19627,6 +21667,9 @@ + + + @@ -19818,16 +21861,31 @@ + + + + + + + + + + + + + + + @@ -20370,6 +22428,14 @@ + + + + + + + + @@ -20735,6 +22801,11 @@ + + + + + @@ -20759,6 +22830,14 @@ + + + + + + + + @@ -20767,6 +22846,14 @@ + + + + + + + + @@ -21480,6 +23567,16 @@ + + + + + + + + + + @@ -22231,6 +24328,11 @@ + + + + + @@ -22540,6 +24642,14 @@ + + + + + + + + @@ -22596,6 +24706,14 @@ + + + + + + + + @@ -22652,6 +24770,14 @@ + + + + + + + + @@ -22708,6 +24834,14 @@ + + + + + + + + @@ -22764,6 +24898,14 @@ + + + + + + + + @@ -22820,6 +24962,14 @@ + + + + + + + + @@ -22876,6 +25026,14 @@ + + + + + + + + @@ -22932,6 +25090,14 @@ + + + + + + + + @@ -22988,6 +25154,14 @@ + + + + + + + + @@ -23044,6 +25218,14 @@ + + + + + + + + @@ -23100,6 +25282,14 @@ + + + + + + + + @@ -23156,6 +25346,14 @@ + + + + + + + + @@ -23894,6 +26092,14 @@ + + + + + + + + @@ -24109,6 +26315,14 @@ + + + + + + + + @@ -24165,6 +26379,14 @@ + + + + + + + + @@ -24285,6 +26507,16 @@ + + + + + + + + + + @@ -24340,6 +26572,14 @@ + + + + + + + + @@ -24517,6 +26757,16 @@ + + + + + + + + + + @@ -24557,6 +26807,14 @@ + + + + + + + + @@ -24577,6 +26835,16 @@ + + + + + + + + + + @@ -24617,6 +26885,14 @@ + + + + + + + + @@ -25330,6 +27606,14 @@ + + + + + + + + @@ -25354,6 +27638,14 @@ + + + + + + + + @@ -25369,6 +27661,11 @@ + + + + + @@ -25393,6 +27690,14 @@ + + + + + + + + @@ -25417,6 +27722,14 @@ + + + + + + + + @@ -25508,6 +27821,14 @@ + + + + + + + + @@ -25532,6 +27853,14 @@ + + + + + + + + @@ -25596,6 +27925,14 @@ + + + + + + + + @@ -25636,6 +27973,14 @@ + + + + + + + + @@ -25801,6 +28146,14 @@ + + + + + + + + @@ -25899,6 +28252,14 @@ + + + + + + + + @@ -25968,6 +28329,14 @@ + + + + + + + + @@ -26032,6 +28401,14 @@ + + + + + + + + @@ -26136,6 +28513,14 @@ + + + + + + + + @@ -26213,6 +28598,14 @@ + + + + + + + + @@ -26277,6 +28670,14 @@ + + + + + + + + @@ -26354,6 +28755,14 @@ + + + + + + + + @@ -26450,6 +28859,14 @@ + + + + + + + + @@ -26522,6 +28939,14 @@ + + + + + + + + @@ -26594,6 +29019,14 @@ + + + + + + + + @@ -26693,6 +29126,17 @@ + + + + + + + + + + + @@ -26765,6 +29209,14 @@ + + + + + + + + @@ -26837,6 +29289,14 @@ + + + + + + + + @@ -26909,6 +29369,14 @@ + + + + + + + + @@ -26991,6 +29459,14 @@ + + + + + + + + @@ -27063,6 +29539,14 @@ + + + + + + + + @@ -27135,6 +29619,14 @@ + + + + + + + + @@ -27175,6 +29667,14 @@ + + + + + + + + @@ -27247,6 +29747,14 @@ + + + + + + + + @@ -27319,6 +29827,14 @@ + + + + + + + + @@ -27404,6 +29920,14 @@ + + + + + + + + @@ -27524,6 +30048,14 @@ + + + + + + + + @@ -27620,6 +30152,14 @@ + + + + + + + + @@ -27692,6 +30232,14 @@ + + + + + + + + @@ -27764,6 +30312,14 @@ + + + + + + + + @@ -27836,6 +30392,14 @@ + + + + + + + + @@ -27908,6 +30472,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -28126,6 +30730,25 @@ + + + + + + + + + + + + + + + + + + + @@ -28275,6 +30898,11 @@ + + + + + @@ -28680,6 +31308,14 @@ + + + + + + + + @@ -28752,6 +31388,14 @@ + + + + + + + + @@ -28824,6 +31468,14 @@ + + + + + + + + @@ -28880,6 +31532,14 @@ + + + + + + + + @@ -28888,11 +31548,29 @@ + + + + + + + + + + + + + + + + + + @@ -28967,6 +31645,13 @@ + + + + + + + @@ -29036,6 +31721,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -29210,6 +31926,11 @@ + + + + + @@ -29442,6 +32163,16 @@ + + + + + + + + + + @@ -29463,6 +32194,22 @@ + + + + + + + + + + + + + + + + @@ -29487,6 +32234,22 @@ + + + + + + + + + + + + + + + + @@ -29510,6 +32273,22 @@ + + + + + + + + + + + + + + + + @@ -29534,6 +32313,22 @@ + + + + + + + + + + + + + + + + @@ -29542,6 +32337,14 @@ + + + + + + + + @@ -29724,6 +32527,9 @@ + + + @@ -29761,14 +32567,36 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -30102,6 +32930,14 @@ + + + + + + + + @@ -30150,6 +32986,14 @@ + + + + + + + + @@ -30165,6 +33009,11 @@ + + + + + @@ -30213,6 +33062,14 @@ + + + + + + + + @@ -30261,6 +33118,14 @@ + + + + + + + + @@ -30309,6 +33174,14 @@ + + + + + + + + @@ -30373,6 +33246,14 @@ + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8bdaf60..f8e1ee3 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2a84e18..bad7c24 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index ef07e01..adff685 100755 --- a/gradlew +++ b/gradlew @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee..e509b2d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/jacoco.gradle.kts b/jacoco.gradle.kts new file mode 100644 index 0000000..9123d92 --- /dev/null +++ b/jacoco.gradle.kts @@ -0,0 +1,133 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Jimly Asshiddiqy + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import org.gradle.testing.jacoco.plugins.JacocoPlugin +import org.gradle.testing.jacoco.plugins.JacocoPluginExtension +import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register + + +apply() + +// Use version from version catalog +configure { + toolVersion = extensions + .getByType() + .named("libs") + .findVersion("jacoco") + .get() + .requiredVersion +} + +val flavor = "gplay" +val buildType = "Debug" +val variant = "${flavor.replaceFirstChar(Char::titlecase)}${buildType.replaceFirstChar(Char::titlecase)}" + +val fileFilter = listOf( + // data binding + "**/databinding/*", + "android/databinding/**/*.class", + "**/android/databinding/*Binding.class", + "**/android/databinding/*", + "**/androidx/databinding/*", + "**/BR.*", + // android + "**/R.class", + "**/R\$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*Test*.*", + "android/**/*.*", + // kotlin + "**/*MapperImpl*.*", + "**/*\$ViewInjector*.*", + "**/*\$ViewBinder*.*", + "**/*Component*.*", + "**/*BR*.*", + "**/Manifest*.*", + "**/*\$Lambda\$*.*", + "**/*Companion*.*", + "**/*Module*.*", + "**/*Dagger*.*", + "**/*Hilt*.*", + "**/*MembersInjector*.*", + "**/*_MembersInjector.class", + "**/*_Factory*.*", + "**/*_Provide*Factory*.*", + "**/*Extensions*.*", + // sealed and data classes + "**/*\$Result.*", + "**/*\$Result\$*.*", + // adapters generated by moshi + "**/*JsonAdapter.*", + // Hilt + "**/*Module.kt", + "**/di/**", + "dagger.hilt.internal/*", + "hilt_aggregated_deps/*", + // Navigation component / inlined classes + "**/*Args*.*", + "**/*Directions*.*", + "**/*inlined*.class", + // Jetpack Compose generated + "**/composables/**" +) + +// Configure test tasks to include Jacoco instrumentation +tasks.withType().configureEach { + extensions.configure(JacocoTaskExtension::class) { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } +} + +// Main Jacoco report task +val jacocoReport = tasks.register("jacocoTest${variant}UnitTestReport") { + dependsOn("test${variant}UnitTest") + + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + + // Output locations + html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/${name}")) + xml.outputLocation.set(layout.buildDirectory.file("reports/jacoco/${name}/jacoco.xml")) + } + + // Directories + val kotlinClasses = fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/${variant}")) + val javacClasses = fileTree(layout.buildDirectory.dir("intermediates/javac/${variant}/classes")) + + classDirectories.setFrom(files(kotlinClasses, javacClasses)) + sourceDirectories.setFrom( + files( + "src/main/java", + "src/$flavor/java", + "src/${buildType.lowercase()}/java" + ) + ) + executionData.setFrom( + fileTree(layout.buildDirectory.asFile) { + include( + "jacoco/test${variant}UnitTest.exec", + "outputs/unit_test_code_coverage/${variant}UnitTest/test${variant}UnitTest.exec" + ) + } + ) +} + +// Backward compatibility alias +tasks.register("jacocoTest${variant}UnitTest") { + dependsOn("test${variant}UnitTest", jacocoReport) +} diff --git a/scripts/analysis/analysis-wrapper.sh b/scripts/analysis/analysis-wrapper.sh index 5d5f8b0..e967da8 100755 --- a/scripts/analysis/analysis-wrapper.sh +++ b/scripts/analysis/analysis-wrapper.sh @@ -14,9 +14,6 @@ PR_NUMBER=$5 stableBranch="master" repository="android" -ruby scripts/analysis/lint-up.rb -lintValue=$? - curl "https://www.kaminsky.me/nc-dev/$repository-findbugs/$stableBranch.xml" -o "/tmp/$stableBranch.xml" [[ ! -e "/tmp/$stableBranch.xml" ]] && exit 1 @@ -36,18 +33,10 @@ if [ "$BRANCH" = $stableBranch ]; then echo "New spotbugs result for $stableBranch at: https://www.kaminsky.me/nc-dev/$repository-findbugs/$stableBranch.html" curl -u "${LOG_USERNAME}:${LOG_PASSWORD}" -X PUT https://nextcloud.kaminsky.me/remote.php/dav/files/${LOG_USERNAME}/$repository-findbugs/$stableBranch.html --upload-file app/build/reports/spotbugs/spotbugs.html curl 2>/dev/null -u "${LOG_USERNAME}:${LOG_PASSWORD}" -X PUT "https://nextcloud.kaminsky.me/remote.php/dav/files/${LOG_USERNAME}/$repository-findbugs/$stableBranch.xml" --upload-file app/build/reports/spotbugs/gplayDebug.xml - - if [ $lintValue -ne 1 ]; then - echo "New lint result for $stableBranch at: https://www.kaminsky.me/nc-dev/$repository-lint/$stableBranch.html" - curl -u "${LOG_USERNAME}:${LOG_PASSWORD}" -X PUT https://nextcloud.kaminsky.me/remote.php/dav/files/${LOG_USERNAME}/$repository-lint/$stableBranch.html --upload-file app/build/reports/lint/lint.html - exit 0 - fi else if [ -e "${BUILD_NUMBER}" ]; then 6=$stableBranch"-"$(date +%F) fi - echo "New lint results at https://www.kaminsky.me/nc-dev/$repository-lint/${BUILD_NUMBER}.html" - curl 2>/dev/null -u "${LOG_USERNAME}:${LOG_PASSWORD}" -X PUT "https://nextcloud.kaminsky.me/remote.php/dav/files/${LOG_USERNAME}/$repository-lint/${BUILD_NUMBER}.html" --upload-file app/build/reports/lint/lint.html echo "New spotbugs results at https://www.kaminsky.me/nc-dev/$repository-findbugs/${BUILD_NUMBER}.html" curl 2>/dev/null -u "${LOG_USERNAME}:${LOG_PASSWORD}" -X PUT "https://nextcloud.kaminsky.me/remote.php/dav/files/${LOG_USERNAME}/$repository-findbugs/${BUILD_NUMBER}.html" --upload-file app/build/reports/spotbugs/spotbugs.html @@ -59,41 +48,13 @@ else curl_gh -X DELETE "https://api.github.com/repos/nextcloud/$repository/issues/comments/$comment" done - # lint and spotbugs file must exist - if [ ! -s app/build/reports/lint/lint.html ] ; then - echo "lint.html file is missing!" - exit 1 - fi - + # spotbugs file must exist if [ ! -s app/build/reports/spotbugs/spotbugs.html ] ; then echo "spotbugs.html file is missing!" exit 1 fi # add comment with results - lintResultNew=$(grep "Lint Report.* [0-9]* warning" app/build/reports/lint/lint.html | cut -f2 -d':' |cut -f1 -d'<') - - lintErrorNew=$(echo $lintResultNew | grep "[0-9]* error" -o | cut -f1 -d" ") - if ( [ -z $lintErrorNew ] ); then - lintErrorNew=0 - fi - - lintWarningNew=$(echo $lintResultNew | grep "[0-9]* warning" -o | cut -f1 -d" ") - if ( [ -z $lintWarningNew ] ); then - lintWarningNew=0 - fi - - lintResultOld=$(curl 2>/dev/null "https://raw.githubusercontent.com/nextcloud/$repository/$stableBranch/scripts/analysis/lint-results.txt") - lintErrorOld=$(echo $lintResultOld | grep "[0-9]* error" -o | cut -f1 -d" ") - if ( [ -z $lintErrorOld ] ); then - lintErrorOld=0 - fi - - lintWarningOld=$(echo $lintResultOld | grep "[0-9]* warning" -o | cut -f1 -d" ") - if ( [ -z $lintWarningOld ] ); then - lintWarningOld=0 - fi - if [ $stableBranch = "master" ] ; then codacyValue=$(curl 2>/dev/null https://app.codacy.com/dashboards/breakdown\?projectId\=44248 | grep "total issues" | cut -d">" -f3 | cut -d"<" -f1) codacyResult="

Codacy

$codacyValue" @@ -101,14 +62,8 @@ else codacyResult="" fi - lintResult="

Lint

Type$stableBranchPR
Warnings$lintWarningOld$lintWarningNew
Errors$lintErrorOld$lintErrorNew
" - spotbugsResult="

SpotBugs

$(scripts/analysis/spotbugsComparison.py "/tmp/$stableBranch.xml" app/build/reports/spotbugs/gplayDebug.xml --link-new "https://www.kaminsky.me/nc-dev/$repository-findbugs/${BUILD_NUMBER}.html" --link-base "https://www.kaminsky.me/nc-dev/$repository-findbugs/$stableBranch.html")" - if ( [ $lintValue -eq 1 ] ) ; then - lintMessage="

Lint increased!

" - fi - if ( [ $spotbugsValue -eq 1 ] ) ; then spotbugsMessage="

SpotBugs increased!

" fi @@ -125,7 +80,7 @@ else notNull="org.jetbrains.annotations.* is used. Please use androidx.annotation.* instead.

" fi - bodyContent="$codacyResult $lintResult $spotbugsResult $lintMessage $spotbugsMessage $gplayLimitation $notNull" + bodyContent="$codacyResult $spotbugsResult $spotbugsMessage $gplayLimitation $notNull" echo "$bodyContent" >> "$GITHUB_STEP_SUMMARY" payload="{ \"body\" : \"$bodyContent\" }" curl_gh -X POST "https://api.github.com/repos/nextcloud/$repository/issues/${PR_NUMBER}/comments" -d "$payload" @@ -134,10 +89,6 @@ else exit 1 fi - if [ ! $lintValue -eq 2 ]; then - exit $lintValue - fi - if [ -n "$notNull" ]; then exit 1 fi diff --git a/scripts/analysis/detectWrongSettings.sh b/scripts/analysis/detectWrongSettings.sh index c3c6aef..c8cfbf2 100755 --- a/scripts/analysis/detectWrongSettings.sh +++ b/scripts/analysis/detectWrongSettings.sh @@ -6,12 +6,20 @@ snapshotCount=$(./gradlew dependencies | grep SNAPSHOT -c) betaCount=$(grep "true" app/src/main/res/values/setup.xml -c) -libraryHash=$(grep androidLibraryVersion build.gradle | cut -d= -f2 | tr -d \") + +# Read androidLibraryVersion from TOML +libraryHash=$(grep 'androidLibraryVersion' gradle/libs.versions.toml \ + | cut -d '=' -f2 \ + | tr -d ' "' ) baseBranch="master" -lastHashes=$(curl "https://api.github.com/repos/nextcloud/android-library/commits?sha=$baseBranch" | jq ".[] .sha" | head -n 20) -if [[ $(echo "$lastHashes" | grep -c $libraryHash) -ne 1 ]]; then +# Fetch last 10 commit SHAs from GitHub, without quotes +lastHashes=$(curl -s "https://api.github.com/repos/nextcloud/android-library/commits?sha=$baseBranch" \ + | jq -r '.[].sha' | head -n 10) + +# Check if libraryHash is in lastHashes +if ! echo "$lastHashes" | grep -q "^$libraryHash$"; then echo "Library commit not within last 10 hashes, please rebase!" exit 1 fi @@ -20,10 +28,10 @@ if [[ $snapshotCount -gt 0 ]] ; then echo "Snapshot found in dependencies" exit 1 fi + if [[ $betaCount -gt 0 ]] ; then echo "Beta is set in setup.xml" exit 1 fi - -exit 0 +exit 0 diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index a332ac9..005c814 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 11 errors and 47 warnings + Lint Report: 11 errors and 41 warnings diff --git a/scripts/buildDev b/scripts/buildDev index d82d268..3a0e952 100755 --- a/scripts/buildDev +++ b/scripts/buildDev @@ -5,15 +5,15 @@ # SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only # date=$(date +%Y%m%d) -oldLibraryCommit=$(grep "androidLibraryVersion\ =" build.gradle) +oldLibraryCommit=$(grep "androidLibraryVersion\ =" gradle/libs.versions.toml) libraryCommit=$(curl https://api.github.com/repos/nextcloud/android-library/commits/master | jq .sha | sed s'/\"//g') # use current date for version code/name -sed -i "/versionDev/,/\}/ s/versionCode .*/versionCode $date/" app/build.gradle -sed -i "/versionDev/,/\}/ s/versionName .*/versionName \"$date\"/" app/build.gradle +sed -i "/versionDev/,/\}/ s/versionCode .*/versionCode = $date/" app/build.gradle.kts +sed -i "/versionDev/,/\}/ s/versionName .*/versionName = \"$date\"/" app/build.gradle.kts # change library -sed -i s"#androidLibraryVersion\ =.*#androidLibraryVersion =\"$libraryCommit\"#" build.gradle +sed -i s"#androidLibraryVersion\ =.*#androidLibraryVersion = \"$libraryCommit\"#" gradle/libs.versions.toml ./gradlew --console=plain --dependency-verification lenient -q --write-verification-metadata sha256,pgp help # build signed apk diff --git a/scripts/checkIfRunDrone.sh b/scripts/checkIfRunDrone.sh index 12b1f0c..a37ffd7 100755 --- a/scripts/checkIfRunDrone.sh +++ b/scripts/checkIfRunDrone.sh @@ -12,7 +12,7 @@ if [ -z "$PR_NUMBER" ] ; then fi export BRANCH=$(scripts/analysis/getBranchBase.sh "$PR_NUMBER" | sed 's/"//g') -if [ "$(git diff --name-only "origin/$BRANCH" | grep -cE "^app/src|screenshots|build.gradle|.drone.yml|gradle")" -eq 0 ] ; then +if [ "$(git diff --name-only "origin/$BRANCH" | grep -cE "^app/src|screenshots|build.gradle.kts|.drone.yml|gradle")" -eq 0 ] ; then echo "No source files changed" exit 1 else diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c0ebf04 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,54 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Jimly Asshiddiqy + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +rootProject.name = "Nextcloud" + +pluginManagement { + resolutionStrategy.eachPlugin { + if (requested.id.id == "shot") useModule("com.karumi:shot:${requested.version}") + } + + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + gradlePluginPortal() + mavenCentral() + } +} + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + maven("https://jitpack.io") + } +} +//includeBuild("../android-common") { +// dependencySubstitution { +// substitute module("com.github.nextcloud.android-common:ui") using project(":ui") +// } +//} + +//includeBuild("../android-library") { +// dependencySubstitution { +// substitute module('com.github.nextcloud:android-library') using project(':library') // broken on gradle 8.14.2, so use 8.13 if needed +// } +//} + +include(":app", ":appscan") \ No newline at end of file diff --git a/src/generic/fastlane/metadata/android/is-IS/full_description.txt b/src/generic/fastlane/metadata/android/is-IS/full_description.txt index 7eda729..78b7dda 100644 --- a/src/generic/fastlane/metadata/android/is-IS/full_description.txt +++ b/src/generic/fastlane/metadata/android/is-IS/full_description.txt @@ -10,7 +10,7 @@ Eiginleikar: * Haltu öllu uppfærðu í gegnum tilkynningar * Styður marga aðganga fyrir hvern notanda * Tryggðu aðgang að gögnunum þínum með fingrafari eða PIN-númeri -* Samþætting við DAVx5 (áður þekkt sem DAVdroid) fyrir auðvelda uppssetningu á samstilltu dagatali og tengiliðum +* Samþætting við DAVx⁵ (áður þekkt sem DAVdroid) fyrir auðvelda uppssetningu á samstilltu dagatali og tengiliðum Tilkynntu öll vandamál á https://github.com/nextcloud/android/issues og ræddu um þennan hugbúnað á https://help.nextcloud.com/c/clients/android