main branch updated

This commit is contained in:
Fr4nz D13trich 2025-11-20 16:16:40 +01:00
parent 3d33d3fe49
commit 9a05dc1657
353 changed files with 16802 additions and 2995 deletions

30
.codecov.yml Normal file
View file

@ -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*/*"

20
.devcontainer/Dockerfile Normal file
View file

@ -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"

View file

@ -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

9
.devcontainer/README.md Normal file
View file

@ -0,0 +1,9 @@
<!--
~ SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
-->
# 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`.

View file

@ -0,0 +1,3 @@
ANDROID_HOME=/usr/lib/android-sdk
JAVA_OPTS="-Xmx8192M"
GRADLE_OPTS="-Dorg.gradle.daemon=true"

View file

@ -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

View file

@ -0,0 +1,4 @@
{
"name": "NextcloudAndroid",
"dockerFile": "Dockerfile",
}

View file

@ -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

190
.drone.yml Normal file
View file

@ -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'#<bool name="is_beta">false</bool>#<bool name="is_beta">true</bool>#'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'#<bool name="is_beta">false</bool>#<bool name="is_beta">true</bool>#'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'#<bool name="is_beta">false</bool>#<bool name="is_beta">true</bool>#'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
...

51
.editorconfig Normal file
View file

@ -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

59
.gitignore vendored Normal file
View file

@ -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

205
.idea/codeStyles/Project.xml generated Normal file
View file

@ -0,0 +1,205 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99" />
<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99" />
<option name="PACKAGES_TO_USE_IMPORT_ON_DEMAND">
<value />
</option>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
</value>
</option>
<option name="RIGHT_MARGIN" value="120" />
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
</value>
</option>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<MarkdownNavigatorCodeStyleSettings>
<option name="RIGHT_MARGIN" value="120" />
</MarkdownNavigatorCodeStyleSettings>
<codeStyleSettings language="JAVA">
<option name="ALIGN_MULTILINE_PARAMETERS_IN_CALLS" value="true" />
<option name="ALIGN_MULTILINE_METHOD_BRACKETS" value="true" />
<option name="WRAP_COMMENTS" value="true" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<option name="FIELD_ANNOTATION_WRAP" value="0" />
</codeStyleSettings>
<codeStyleSettings language="Markdown">
<option name="RIGHT_MARGIN" value="120" />
</codeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

50
.pullapprove.yml Normal file
View file

@ -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

10
.tx/config Normal file
View file

@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com
[o:nextcloud:p:nextcloud:r:android]
file_filter = app/src/main/res/values-<lang>/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

3
.tx/config.license Normal file
View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: 2012 Bartosz Przybylski <bart.p.pl@gmail.com>
SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only

View file

@ -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

View file

@ -12,6 +12,9 @@ height="80">](https://play.google.com/store/apps/details?id=com.nextcloud.client
[<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.nextcloud.client/)
[<img src="https://github.com/user-attachments/assets/713d71c5-3dec-4ec4-a3f2-8d28d025a9c6"
alt="Get it with Obtainium"
height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.nextcloud.client%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fnextcloud%2Fandroid%22%2C%22author%22%3A%22nextcloud%22%2C%22name%22%3A%22Nextcloud%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Enextcloud.*%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22Nextcloud%20ist%20eine%20Cloudanwendung%2C%20die%20selbst%20gehostet%20werden%20kann.%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Atrue%7D%22%2C%22overrideSource%22%3Anull%7D)
Signing certificate fingerprint to [verify](https://developer.android.com/studio/command-line/apksigner#usage-verify) the APK:
- APK with "gplay" name, found [here](https://github.com/nextcloud/android/releases) or distributed via Google Play Store

120
Readme-AR.md Normal file
View file

@ -0,0 +1,120 @@
<!--
~ SPDX-FileCopyrightText: 2025 Saeed <saidhany244@example.com>
~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
-->
# تطبيق [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)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" alt="تحميل من Google Play" height="80">](https://play.google.com/store/apps/details?id=com.nextcloud.client)
[<img src="https://f-droid.org/badge/get-it-on.png" alt="احصل عليه من F-Droid" height="80">](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'
```
- استخدم الناتج كـ `<processID>` في الأمر التالي:
```bash
adb logcat --pid=<processID> > "%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 حاليًا.

522
app/build.gradle.kts Normal file
View file

@ -0,0 +1,522 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Jimly Asshiddiqy <jimly.asshiddiqy@accenture.com>
* 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<ShotExtension> {
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>("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>("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<SpotBugsTask>().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<JavaCompile>().configureEach {
options.isFork = true
// Enable Incremental Compilation
options.isIncremental = true
}
tasks.withType<Test>().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)
}

View file

@ -39,6 +39,10 @@
<ignore path="**/values-**/strings.xml" />
</issue>
<issue id="PluralsCandidate">
<ignore path="**/values-**/strings.xml" />
</issue>
<issue id="ExtraTranslation">
<ignore path="**/strings.xml"/>
<ignore path="**/values-b+en+001/strings.xml"/>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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", "")

View file

@ -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

View file

@ -202,7 +202,7 @@ class TransferManagerConnectionTest {
connection.onServiceConnected(componentName, binder)
// WHEN
// is runnign flag accessed
// is running flag accessed
val isRunning = connection.isRunning
// THEN

View file

@ -0,0 +1,111 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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)
}
}

View file

@ -0,0 +1,90 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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))
}
}

View file

@ -0,0 +1,78 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<File>()
@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)
}
}

View file

@ -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" }
}
}

View file

@ -0,0 +1,63 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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())
}
}

View file

@ -0,0 +1,204 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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)
}
}

View file

@ -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)
}
}

View file

@ -400,11 +400,6 @@ public abstract class AbstractIT {
public boolean isPowerSavingEnabled() {
return false;
}
@Override
public boolean isPowerSavingExclusionAvailable() {
return false;
}
};
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);

View file

@ -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);

View file

@ -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() {

View file

@ -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)));
}
}

View file

@ -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()

View file

@ -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();

View file

@ -822,7 +822,7 @@ class FileDetailSharingFragmentIT : AbstractIT() {
val processFragment =
activity.supportFragmentManager.findFragmentByTag(FileDetailsSharingProcessFragment.TAG) as
FileDetailsSharingProcessFragment
processFragment.onBackPressed()
processFragment.activity?.onBackPressedDispatcher?.onBackPressed()
}
}
}

View file

@ -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()

View file

@ -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)
}

View file

@ -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);

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ Nextcloud - Android Client
~
~ SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
@ -53,8 +52,10 @@
must request the FOREGROUND_SERVICE permission
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Runtime permissions introduced in Android 13 (API level 33) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <!-- Needed for Android 14 (API level 34) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"
tools:ignore="PhotoAndVideoPolicy,SelectedPhotoAccess" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"
tools:ignore="PhotoAndVideoPolicy,SelectedPhotoAccess" /> <!-- Needed for Android 14 (API level 34) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!--
@ -112,6 +113,7 @@
android:name=".MainApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_config"
android:icon="@mipmap/ic_launcher"
android:installLocation="internalOnly"
@ -121,13 +123,13 @@
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="false"
android:theme="@style/Theme.ownCloud.Toolbar"
android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute"
tools:replace="android:allowBackup">
<meta-data android:name="android.content.APP_RESTRICTIONS"
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_config" />
<activity
@ -159,7 +161,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -169,7 +173,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -179,7 +185,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -189,7 +197,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/..*/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -199,7 +209,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -209,7 +221,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -219,7 +233,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -229,7 +245,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/..*/..*/f/..*" />
<!-- path pattern to handle deep link -->
<data android:pathPattern="/app/..*" />
@ -352,6 +370,7 @@
</activity>
<activity
android:name=".ui.activity.SettingsActivity"
android:enableOnBackInvokedCallback="false"
android:exported="false"
android:theme="@style/PreferenceTheme" />
<activity
@ -366,11 +385,11 @@
<service
android:name="com.nextcloud.client.media.BackgroundPlayerService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
@ -584,6 +603,10 @@
</intent-filter>
</receiver>
<receiver
android:name="com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver"
android:exported="false" />
<activity
android:name=".ui.activity.CopyToClipboardActivity"
android:exported="false"

View file

@ -0,0 +1,334 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<Task>,
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
)
}

View file

@ -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<ScreenState?>(null)
val screenState: StateFlow<ScreenState?> = _screenState
@ -44,14 +57,54 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
private val _filteredTaskList = MutableStateFlow<List<Task>?>(null)
val filteredTaskList: StateFlow<List<Task>?> = _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
}

View file

@ -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
)
}
}

View file

@ -0,0 +1,17 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<Task>, accountName: String)
suspend fun getCachedTasks(accountName: String): List<Task>
suspend fun insertTask(task: Task, accountName: String)
suspend fun deleteTask(id: Long, accountName: String)
}

View file

@ -0,0 +1,69 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<Task>, accountName: String) {
val entities = tasks.map { it.toEntity(accountName) }
assistantDao.insertAssistantTasks(entities)
}
override suspend fun getCachedTasks(accountName: String): List<Task> {
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
}

View file

@ -0,0 +1,35 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<Task>()
private val mutex = Mutex()
override suspend fun cacheTasks(tasks: List<Task>, accountName: String) {
mutex.withLock {
this.tasks.clear()
this.tasks.addAll(tasks)
}
}
override suspend fun getCachedTasks(accountName: String): List<Task> = 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 } }
}
}

View file

@ -0,0 +1,22 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<TaskTypeData>?
fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult<Void>
fun getTaskList(taskType: String): List<Task>?
fun deleteTask(id: Long): RemoteOperationResult<Void>
}

View file

@ -0,0 +1,80 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<TaskTypeData>? {
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<Void> = if (supportsV2) {
CreateTaskRemoteOperationV2(input, taskType).execute(client)
} else {
if (taskType.id.isNullOrEmpty()) {
RemoteOperationResult<Void>(ResultCode.CANCELLED)
} else {
CreateTaskRemoteOperationV1(input, taskType.id!!).execute(client)
}
}
@Suppress("ReturnCount")
override fun getTaskList(taskType: String): List<Task>? {
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<Void> = if (supportsV2) {
DeleteTaskRemoteOperationV2(id).execute(client)
} else {
DeleteTaskRemoteOperationV1(id).execute(client)
}
}

View file

@ -0,0 +1,67 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<TaskTypeData> = 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<Void> =
RemoteOperationResult<Void>(RemoteOperationResult.ResultCode.OK)
override fun getTaskList(taskType: String): List<Task> = 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<Void> =
RemoteOperationResult<Void>(RemoteOperationResult.ResultCode.OK)
}

View file

@ -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
)
}
}
}

View file

@ -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<TaskTypeData>, 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)
)
}

View file

@ -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())
}
}

View file

@ -0,0 +1,41 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<AssistantEntity>)
@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<AssistantEntity>
}

View file

@ -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<String>): List<Long>
@Update
fun update(entity: FileEntity)
@ -108,4 +99,16 @@ interface FileDao {
dirType: String = MimeType.DIRECTORY,
webdavType: String = MimeType.WEBDAV_FOLDER
): List<FileEntity>
@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<FileEntity>
}

View file

@ -0,0 +1,40 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<FilesystemEntity>
@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)
}

View file

@ -0,0 +1,26 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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?
}

View file

@ -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<UploadEntity>
@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<UploadEntity>
@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<UploadEntity>
}

View file

@ -0,0 +1,30 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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
)

View file

@ -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?
)

View file

@ -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
)

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
* 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
)
}

View file

@ -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
)
}
}

View file

@ -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]
*/

View file

@ -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() {

View file

@ -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

View file

@ -104,7 +104,7 @@ class DocumentScanActivity :
true
}
android.R.id.home -> {
onBackPressed()
onBackPressedDispatcher.onBackPressed()
true
}
else -> false

View file

@ -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?) {

View file

@ -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<EtmMenuEntry> = listOf()
@SuppressLint("NotifyDataSetChanged")
set(value) {
field = value
notifyDataSetChanged()

View file

@ -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<Transfer>()
@SuppressLint("NotifyDataSetChanged")
fun setStatus(status: TransferManager.Status) {
transfers = listOf(status.pending, status.running, status.completed).flatten().reversed()
notifyDataSetChanged()

View file

@ -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<BackgroundJobManager>,
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<ViewThemeUtils>,
private val localBroadcastManager: Provider<LocalBroadcastManager>,
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
)
}

View file

@ -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<OCFile>): LiveData<JobInfo?>
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<String?> = arrayOf<String?>()
contentUris: Array<String?> = arrayOf()
)
fun cancelTwoWaySyncJob()
@ -142,12 +140,10 @@ interface BackgroundJobManager {
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
fun getFileUploads(user: User): LiveData<List<JobInfo>>
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()
}

View file

@ -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<String?>
contentUris: Array<String?>
) {
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)
}
}

View file

@ -1,18 +1,27 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
* 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<String>()
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
}
}

View file

@ -0,0 +1,135 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<Path>() {
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
}
}

View file

@ -0,0 +1,480 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<String, String>? = 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<String>?, 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<String>?) = 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<Boolean, Boolean, Int> {
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<UploadEntity, OCUpload> {
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
}
}

View file

@ -0,0 +1,66 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<Pair<String, Int>> {
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<Pair<String, Int>>()
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)
}
}
}

View file

@ -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<OCFile>?) {
@ -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()
}

View file

@ -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<DownloadFileOperation>()
private val pendingFolderDownloads: MutableSet<Long> = ConcurrentHashMap.newKeySet<Long>()
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<String> {
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?

View file

@ -0,0 +1,167 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<Long> = ConcurrentHashMap.newKeySet<Long>()
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<OCFile> =
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
}
}
}

View file

@ -0,0 +1,113 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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)
}
}

View file

@ -0,0 +1,25 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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()
}
}

View file

@ -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<OCUpload>
uploads: Array<OCUpload>
): 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<Long>()
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<OCUpload>, 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<OCUpload>) -> 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)
})
}
}
}

View file

@ -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,

View file

@ -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,

View file

@ -132,6 +132,7 @@ class WhatsNewActivity :
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onFinish()
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}

View file

@ -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();

View file

@ -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();

View file

@ -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
}
}

View file

@ -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

View file

@ -335,6 +335,7 @@ class SetStatusMessageBottomSheet(val user: User, val currentStatus: Status?) :
}
}
@SuppressLint("NotifyDataSetChanged")
@VisibleForTesting
fun setPredefinedStatus(predefinedStatus: ArrayList<PredefinedStatus>) {
adapter.list = predefinedStatus

View file

@ -0,0 +1,36 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<V : View> @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
HideViewOnScrollBehavior<V>(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)
}
}
}

View file

@ -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

View file

@ -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,

View file

@ -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
}
}

View file

@ -0,0 +1,67 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<File> {
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<File>): List<String> {
val result = mutableListOf<String>()
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<String>) {
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)
}
}
}

Some files were not shown because too many files have changed in this diff Show more