main branch updated
This commit is contained in:
parent
3d33d3fe49
commit
9a05dc1657
353 changed files with 16802 additions and 2995 deletions
30
.codecov.yml
Normal file
30
.codecov.yml
Normal 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
20
.devcontainer/Dockerfile
Normal 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"
|
||||||
2
.devcontainer/Dockerfile.license
Normal file
2
.devcontainer/Dockerfile.license
Normal 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
9
.devcontainer/README.md
Normal 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`.
|
||||||
3
.devcontainer/devcontainer.env
Normal file
3
.devcontainer/devcontainer.env
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ANDROID_HOME=/usr/lib/android-sdk
|
||||||
|
JAVA_OPTS="-Xmx8192M"
|
||||||
|
GRADLE_OPTS="-Dorg.gradle.daemon=true"
|
||||||
2
.devcontainer/devcontainer.env.license
Normal file
2
.devcontainer/devcontainer.env.license
Normal 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
|
||||||
4
.devcontainer/devcontainer.json
Normal file
4
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"name": "NextcloudAndroid",
|
||||||
|
"dockerFile": "Dockerfile",
|
||||||
|
}
|
||||||
2
.devcontainer/devcontainer.json.license
Normal file
2
.devcontainer/devcontainer.json.license
Normal 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
190
.drone.yml
Normal 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
51
.editorconfig
Normal 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
59
.gitignore
vendored
Normal 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
205
.idea/codeStyles/Project.xml
generated
Normal 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
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
50
.pullapprove.yml
Normal file
50
.pullapprove.yml
Normal 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
10
.tx/config
Normal 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
3
.tx/config.license
Normal 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
|
||||||
92
CHANGELOG.md
92
CHANGELOG.md
|
|
@ -1,3 +1,95 @@
|
||||||
|
## 3.33.0 (September 10, 2025)
|
||||||
|
|
||||||
|
- Migrate to Glide 4
|
||||||
|
- Performance improvements
|
||||||
|
- Fix gallery image scaling
|
||||||
|
- Bugfixes
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 8.1 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/112
|
||||||
|
|
||||||
|
## 3.32.3 (August 21, 2025)
|
||||||
|
|
||||||
|
- Bugfixes
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 8.0 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/114
|
||||||
|
|
||||||
|
## 3.32.2 (July 18, 2025)
|
||||||
|
|
||||||
|
- Resolved image blurriness issue.
|
||||||
|
- Fixed crash occurring in the conflict resolution dialog.
|
||||||
|
- Addressed crash in the upload finish receiver event handler.
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 8.0 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/115
|
||||||
|
|
||||||
|
## 3.32.1 (July 14, 2025)
|
||||||
|
|
||||||
|
- Bug fixes.
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 8.0 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/113
|
||||||
|
|
||||||
|
## 3.32.0 (July 2, 2025)
|
||||||
|
|
||||||
|
- Minimum supported Android version is 8.0.
|
||||||
|
- Scrolling performance has been increased in the media tab.
|
||||||
|
- Multi-select feature added to the media tab.
|
||||||
|
- Custom share permissions have been added.
|
||||||
|
- Bug fixes.
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 8.0 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/107
|
||||||
|
|
||||||
|
## 3.31.4 (June 3, 2025)
|
||||||
|
|
||||||
|
- Add missing auto migration
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 7.1 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/110
|
||||||
|
|
||||||
|
## 3.31.3 (May 28, 2025)
|
||||||
|
|
||||||
|
- fix simple sign up
|
||||||
|
- bugfixes
|
||||||
|
- update translations
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 7.1 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/110
|
||||||
|
|
||||||
|
## 3.31.2 (May 20, 2025)
|
||||||
|
|
||||||
|
- bring back MANAGE_EXTERNAL_STORAGE permission
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 7.1 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/108
|
||||||
|
|
||||||
|
## 3.31.1 (April 3, 2025)
|
||||||
|
|
||||||
|
- Various bug fixes and performance enhancements
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 7.1 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/108
|
||||||
|
|
||||||
|
## 3.31.0 (February 25, 2025)
|
||||||
|
|
||||||
|
- New share layout
|
||||||
|
- Various bug fixes and performance enhancements
|
||||||
|
|
||||||
|
Minimum: NC 18 Server, Android 7.1 Nougat
|
||||||
|
|
||||||
|
For a full list, please see https://github.com/nextcloud/android/milestone/100
|
||||||
|
|
||||||
## 3.30.7 (January 6, 2025)
|
## 3.30.7 (January 6, 2025)
|
||||||
|
|
||||||
- Fix crash of auto upload settings
|
- Fix crash of auto upload settings
|
||||||
|
|
|
||||||
|
|
@ -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"
|
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||||
alt="Get it on F-Droid"
|
alt="Get it on F-Droid"
|
||||||
height="80">](https://f-droid.org/packages/com.nextcloud.client/)
|
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:
|
Signing certificate fingerprint to [verify](https://developer.android.com/studio/command-line/apksigner#usage-verify) the APK:
|
||||||
- APK with "gplay" name, found [here](https://github.com/nextcloud/android/releases) or distributed via Google Play Store
|
- APK with "gplay" name, found [here](https://github.com/nextcloud/android/releases) or distributed via Google Play Store
|
||||||
|
|
|
||||||
120
Readme-AR.md
Normal file
120
Readme-AR.md
Normal 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)لأجهزة أندرويد 📱
|
||||||
|
|
||||||
|
[](https://api.reuse.software/info/github.com/nextcloud/android)
|
||||||
|
[](https://drone.nextcloud.com/nextcloud/android)
|
||||||
|
[](https://app.codacy.com/gh/nextcloud/android/dashboard)
|
||||||
|
[](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
522
app/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,10 @@
|
||||||
<ignore path="**/values-**/strings.xml" />
|
<ignore path="**/values-**/strings.xml" />
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
<issue id="PluralsCandidate">
|
||||||
|
<ignore path="**/values-**/strings.xml" />
|
||||||
|
</issue>
|
||||||
|
|
||||||
<issue id="ExtraTranslation">
|
<issue id="ExtraTranslation">
|
||||||
<ignore path="**/strings.xml"/>
|
<ignore path="**/strings.xml"/>
|
||||||
<ignore path="**/values-b+en+001/strings.xml"/>
|
<ignore path="**/values-b+en+001/strings.xml"/>
|
||||||
|
|
|
||||||
1210
app/schemas/com.nextcloud.client.database.NextcloudDatabase/94.json
Normal file
1210
app/schemas/com.nextcloud.client.database.NextcloudDatabase/94.json
Normal file
File diff suppressed because it is too large
Load diff
1215
app/schemas/com.nextcloud.client.database.NextcloudDatabase/95.json
Normal file
1215
app/schemas/com.nextcloud.client.database.NextcloudDatabase/95.json
Normal file
File diff suppressed because it is too large
Load diff
1293
app/schemas/com.nextcloud.client.database.NextcloudDatabase/96.json
Normal file
1293
app/schemas/com.nextcloud.client.database.NextcloudDatabase/96.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -119,7 +119,8 @@ class SyncedFoldersActivityIT : AbstractIT() {
|
||||||
onIdleSync {
|
onIdleSync {
|
||||||
EspressoIdlingResource.increment()
|
EspressoIdlingResource.increment()
|
||||||
val dialog = sut.buildPowerCheckDialog()
|
val dialog = sut.buildPowerCheckDialog()
|
||||||
dialog.show()
|
sut.showPowerCheckDialog()
|
||||||
|
|
||||||
EspressoIdlingResource.decrement()
|
EspressoIdlingResource.decrement()
|
||||||
|
|
||||||
val screenShotName = createName(testClassName + "_" + "showPowerCheckDialog", "")
|
val screenShotName = createName(testClassName + "_" + "showPowerCheckDialog", "")
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
package com.nextcloud.client.assistant
|
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.AbstractOnServerIT
|
||||||
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
|
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.NextcloudVersion
|
||||||
|
|
@ -18,11 +18,11 @@ import org.junit.Test
|
||||||
@Suppress("MagicNumber")
|
@Suppress("MagicNumber")
|
||||||
class AssistantRepositoryTests : AbstractOnServerIT() {
|
class AssistantRepositoryTests : AbstractOnServerIT() {
|
||||||
|
|
||||||
private var sut: AssistantRepository? = null
|
private var sut: AssistantRemoteRepositoryImpl? = null
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
sut = AssistantRepository(nextcloudClient, capability)
|
sut = AssistantRemoteRepositoryImpl(nextcloudClient, capability)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ class TransferManagerConnectionTest {
|
||||||
connection.onServiceConnected(componentName, binder)
|
connection.onServiceConnected(componentName, binder)
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
// is runnign flag accessed
|
// is running flag accessed
|
||||||
val isRunning = connection.isRunning
|
val isRunning = connection.isRunning
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ package com.nextcloud.utils
|
||||||
import com.nextcloud.utils.autoRename.AutoRename
|
import com.nextcloud.utils.autoRename.AutoRename
|
||||||
import com.owncloud.android.AbstractOnServerIT
|
import com.owncloud.android.AbstractOnServerIT
|
||||||
import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile
|
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.NextcloudVersion
|
||||||
import com.owncloud.android.lib.resources.status.OCCapability
|
import com.owncloud.android.lib.resources.status.OCCapability
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
|
@ -27,6 +28,7 @@ class AutoRenameTests : AbstractOnServerIT() {
|
||||||
testOnlyOnServer(NextcloudVersion.nextcloud_30)
|
testOnlyOnServer(NextcloudVersion.nextcloud_30)
|
||||||
|
|
||||||
capability = capability.apply {
|
capability = capability.apply {
|
||||||
|
isWCFEnabled = CapabilityBooleanType.TRUE
|
||||||
forbiddenFilenameExtensionJson = listOf(
|
forbiddenFilenameExtensionJson = listOf(
|
||||||
"""[" ",".",".part",".part"]""",
|
"""[" ",".",".part",".part"]""",
|
||||||
"""[".",".part",".part"," "]""",
|
"""[".",".part",".part"," "]""",
|
||||||
|
|
@ -238,4 +240,14 @@ class AutoRenameTests : AbstractOnServerIT() {
|
||||||
val expectedFilename = "Foo.Bar.Baz"
|
val expectedFilename = "Foo.Bar.Baz"
|
||||||
assert(result == expectedFilename) { "Expected $expectedFilename but got $result" }
|
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" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
204
app/src/androidTest/java/com/nextcloud/utils/FileHelperTest.kt
Normal file
204
app/src/androidTest/java/com/nextcloud/utils/FileHelperTest.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ package com.nextcloud.utils
|
||||||
import com.nextcloud.utils.fileNameValidator.FileNameValidator
|
import com.nextcloud.utils.fileNameValidator.FileNameValidator
|
||||||
import com.owncloud.android.AbstractOnServerIT
|
import com.owncloud.android.AbstractOnServerIT
|
||||||
import com.owncloud.android.R
|
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.NextcloudVersion
|
||||||
import com.owncloud.android.lib.resources.status.OCCapability
|
import com.owncloud.android.lib.resources.status.OCCapability
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
|
@ -27,6 +28,7 @@ class FileNameValidatorTests : AbstractOnServerIT() {
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
capability = capability.apply {
|
capability = capability.apply {
|
||||||
|
isWCFEnabled = CapabilityBooleanType.TRUE
|
||||||
forbiddenFilenamesJson = """[".htaccess",".htaccess"]"""
|
forbiddenFilenamesJson = """[".htaccess",".htaccess"]"""
|
||||||
forbiddenFilenameBaseNamesJson = """
|
forbiddenFilenameBaseNamesJson = """
|
||||||
["con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4",
|
["con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4",
|
||||||
|
|
@ -228,4 +230,14 @@ class FileNameValidatorTests : AbstractOnServerIT() {
|
||||||
val result = FileNameValidator.checkFolderAndFilePaths(folderPath, listOf(), capability, targetContext)
|
val result = FileNameValidator.checkFolderAndFilePaths(folderPath, listOf(), capability, targetContext)
|
||||||
assertFalse(result)
|
assertFalse(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipValidationWhenWCFDisabled() {
|
||||||
|
capability = capability.apply {
|
||||||
|
isWCFEnabled = CapabilityBooleanType.FALSE
|
||||||
|
}
|
||||||
|
val filename = "abc.txt"
|
||||||
|
val result = FileNameValidator.checkFileName(filename, capability, targetContext)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -400,11 +400,6 @@ public abstract class AbstractIT {
|
||||||
public boolean isPowerSavingEnabled() {
|
public boolean isPowerSavingEnabled() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isPowerSavingExclusionAvailable() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);
|
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);
|
||||||
|
|
|
||||||
|
|
@ -216,11 +216,6 @@ public abstract class AbstractOnServerIT extends AbstractIT {
|
||||||
public boolean isPowerSavingEnabled() {
|
public boolean isPowerSavingEnabled() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isPowerSavingExclusionAvailable() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);
|
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,6 @@ public class UploadIT extends AbstractOnServerIT {
|
||||||
public boolean isPowerSavingEnabled() {
|
public boolean isPowerSavingEnabled() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isPowerSavingExclusionAvailable() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public BatteryStatus getBattery() {
|
public BatteryStatus getBattery() {
|
||||||
|
|
@ -237,11 +231,6 @@ public class UploadIT extends AbstractOnServerIT {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isPowerSavingExclusionAvailable() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public BatteryStatus getBattery() {
|
public BatteryStatus getBattery() {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import com.nextcloud.client.account.CurrentAccountProvider;
|
||||||
import com.nextcloud.client.account.User;
|
import com.nextcloud.client.account.User;
|
||||||
import com.nextcloud.client.account.UserAccountManager;
|
import com.nextcloud.client.account.UserAccountManager;
|
||||||
import com.nextcloud.client.account.UserAccountManagerImpl;
|
import com.nextcloud.client.account.UserAccountManagerImpl;
|
||||||
|
import com.nextcloud.client.database.entity.UploadEntityKt;
|
||||||
import com.nextcloud.test.RandomStringGenerator;
|
import com.nextcloud.test.RandomStringGenerator;
|
||||||
import com.owncloud.android.AbstractIT;
|
import com.owncloud.android.AbstractIT;
|
||||||
import com.owncloud.android.MainApp;
|
import com.owncloud.android.MainApp;
|
||||||
|
|
@ -108,7 +109,7 @@ public class UploadStorageManagerTest extends AbstractIT {
|
||||||
OCUpload upload = createUpload(account);
|
OCUpload upload = createUpload(account);
|
||||||
|
|
||||||
uploads.add(upload);
|
uploads.add(upload);
|
||||||
uploadsStorageManager.storeUpload(upload);
|
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(upload));
|
||||||
}
|
}
|
||||||
|
|
||||||
OCUpload[] storedUploads = uploadsStorageManager.getAllStoredUploads();
|
OCUpload[] storedUploads = uploadsStorageManager.getAllStoredUploads();
|
||||||
|
|
@ -151,17 +152,14 @@ public class UploadStorageManagerTest extends AbstractIT {
|
||||||
account.name);
|
account.name);
|
||||||
|
|
||||||
corruptUpload.setLocalPath(null);
|
corruptUpload.setLocalPath(null);
|
||||||
|
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(corruptUpload));
|
||||||
uploadsStorageManager.storeUpload(corruptUpload);
|
|
||||||
|
|
||||||
uploadsStorageManager.getAllStoredUploads();
|
uploadsStorageManager.getAllStoredUploads();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void getById() {
|
public void getById() {
|
||||||
OCUpload upload = createUpload(account);
|
OCUpload upload = createUpload(account);
|
||||||
long id = uploadsStorageManager.storeUpload(upload);
|
long id = uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(upload));
|
||||||
|
|
||||||
OCUpload newUpload = uploadsStorageManager.getUploadById(id);
|
OCUpload newUpload = uploadsStorageManager.getUploadById(id);
|
||||||
|
|
||||||
assertNotNull(newUpload);
|
assertNotNull(newUpload);
|
||||||
|
|
@ -178,7 +176,7 @@ public class UploadStorageManagerTest extends AbstractIT {
|
||||||
|
|
||||||
private void insertUploads(Account account, int rowsToInsert) {
|
private void insertUploads(Account account, int rowsToInsert) {
|
||||||
for (int i = 0; i < rowsToInsert; i++) {
|
for (int i = 0; i < rowsToInsert; i++) {
|
||||||
uploadsStorageManager.storeUpload(createUpload(account));
|
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(createUpload(account)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,6 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
||||||
override val isPowerSavingEnabled: Boolean
|
override val isPowerSavingEnabled: Boolean
|
||||||
get() = false
|
get() = false
|
||||||
|
|
||||||
override val isPowerSavingExclusionAvailable: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override val battery: BatteryStatus
|
override val battery: BatteryStatus
|
||||||
get() = BatteryStatus()
|
get() = BatteryStatus()
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +324,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
||||||
user,
|
user,
|
||||||
null,
|
null,
|
||||||
ocUpload2,
|
ocUpload2,
|
||||||
NameCollisionPolicy.CANCEL,
|
NameCollisionPolicy.SKIP,
|
||||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||||
targetContext,
|
targetContext,
|
||||||
false,
|
false,
|
||||||
|
|
@ -376,7 +373,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
||||||
user,
|
user,
|
||||||
arrayOf(ocFile2),
|
arrayOf(ocFile2),
|
||||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||||
NameCollisionPolicy.CANCEL
|
NameCollisionPolicy.SKIP
|
||||||
)
|
)
|
||||||
|
|
||||||
shortSleep()
|
shortSleep()
|
||||||
|
|
@ -403,7 +400,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
||||||
user,
|
user,
|
||||||
null,
|
null,
|
||||||
ocUpload,
|
ocUpload,
|
||||||
NameCollisionPolicy.CANCEL,
|
NameCollisionPolicy.SKIP,
|
||||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||||
targetContext,
|
targetContext,
|
||||||
false,
|
false,
|
||||||
|
|
@ -429,7 +426,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
||||||
user,
|
user,
|
||||||
null,
|
null,
|
||||||
ocUpload2,
|
ocUpload2,
|
||||||
NameCollisionPolicy.CANCEL,
|
NameCollisionPolicy.SKIP,
|
||||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||||
targetContext,
|
targetContext,
|
||||||
false,
|
false,
|
||||||
|
|
@ -480,7 +477,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
||||||
user,
|
user,
|
||||||
arrayOf(ocFile2),
|
arrayOf(ocFile2),
|
||||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||||
NameCollisionPolicy.CANCEL
|
NameCollisionPolicy.SKIP
|
||||||
)
|
)
|
||||||
|
|
||||||
shortSleep()
|
shortSleep()
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ public class FileDisplayActivityTest extends AbstractIT {
|
||||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
|
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
|
||||||
Activity activity =
|
Activity activity =
|
||||||
ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(RESUMED).iterator().next();
|
ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(RESUMED).iterator().next();
|
||||||
if (activity instanceof WhatsNewActivity) {
|
if (activity instanceof WhatsNewActivity whatsNewActivity) {
|
||||||
activity.onBackPressed();
|
whatsNewActivity.getOnBackPressedDispatcher().onBackPressed();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
scenario.recreate();
|
scenario.recreate();
|
||||||
|
|
|
||||||
|
|
@ -822,7 +822,7 @@ class FileDetailSharingFragmentIT : AbstractIT() {
|
||||||
val processFragment =
|
val processFragment =
|
||||||
activity.supportFragmentManager.findFragmentByTag(FileDetailsSharingProcessFragment.TAG) as
|
activity.supportFragmentManager.findFragmentByTag(FileDetailsSharingProcessFragment.TAG) as
|
||||||
FileDetailsSharingProcessFragment
|
FileDetailsSharingProcessFragment
|
||||||
processFragment.onBackPressed()
|
processFragment.activity?.onBackPressedDispatcher?.onBackPressed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ class UnifiedSearchFragmentIT : AbstractIT() {
|
||||||
scenario.onActivity { activity ->
|
scenario.onActivity { activity ->
|
||||||
onIdleSync {
|
onIdleSync {
|
||||||
EspressoIdlingResource.increment()
|
EspressoIdlingResource.increment()
|
||||||
val sut = UnifiedSearchFragment.newInstance(null, null)
|
val sut = UnifiedSearchFragment.newInstance(null, null, "/")
|
||||||
activity.addFragment(sut)
|
activity.addFragment(sut)
|
||||||
|
|
||||||
sut.onSearchResultChanged(
|
sut.onSearchResultChanged(
|
||||||
|
|
@ -83,7 +83,7 @@ class UnifiedSearchFragmentIT : AbstractIT() {
|
||||||
onIdleSync {
|
onIdleSync {
|
||||||
EspressoIdlingResource.increment()
|
EspressoIdlingResource.increment()
|
||||||
|
|
||||||
val sut = UnifiedSearchFragment.newInstance(null, null)
|
val sut = UnifiedSearchFragment.newInstance(null, null, "/")
|
||||||
val testViewModel = UnifiedSearchViewModel(activity.application)
|
val testViewModel = UnifiedSearchViewModel(activity.application)
|
||||||
testViewModel.setConnectivityService(activity.connectivityServiceMock)
|
testViewModel.setConnectivityService(activity.connectivityServiceMock)
|
||||||
val localRepository = UnifiedSearchFakeRepository()
|
val localRepository = UnifiedSearchFakeRepository()
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,14 @@ class CapabilityUtilsIT : AbstractIT() {
|
||||||
assertTrue(test(OwnCloudVersion.nextcloud_20))
|
assertTrue(test(OwnCloudVersion.nextcloud_20))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun test(version: OwnCloudVersion): Boolean =
|
@Test
|
||||||
CapabilityUtils.checkOutdatedWarning(targetContext.resources, version, false)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import java.io.FileInputStream;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.security.InvalidKeyException;
|
import java.security.InvalidKeyException;
|
||||||
import java.security.Key;
|
import java.security.Key;
|
||||||
import java.security.KeyFactory;
|
import java.security.KeyFactory;
|
||||||
|
|
@ -96,7 +97,11 @@ public final class PushUtils {
|
||||||
if (!new File(privateKeyPath).exists() && !new File(publicKeyPath).exists()) {
|
if (!new File(privateKeyPath).exists() && !new File(publicKeyPath).exists()) {
|
||||||
try {
|
try {
|
||||||
if (!keyPathFile.exists()) {
|
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");
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||||
keyGen.initialize(2048);
|
keyGen.initialize(2048);
|
||||||
|
|
@ -304,8 +309,12 @@ public final class PushUtils {
|
||||||
try {
|
try {
|
||||||
if (!new File(path).exists()) {
|
if (!new File(path).exists()) {
|
||||||
File newFile = new File(path);
|
File newFile = new File(path);
|
||||||
newFile.getParentFile().mkdirs();
|
try {
|
||||||
newFile.createNewFile();
|
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 = new FileOutputStream(path);
|
||||||
keyFileOutputStream.write(encoded);
|
keyFileOutputStream.write(encoded);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
<!--
|
|
||||||
~ Nextcloud - Android Client
|
~ Nextcloud - Android Client
|
||||||
~
|
~
|
||||||
~ SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
|
~ SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
|
||||||
|
|
@ -53,8 +52,10 @@
|
||||||
must request the FOREGROUND_SERVICE permission
|
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.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_IMAGES"
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <!-- Needed for Android 14 (API level 34) -->
|
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_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<!--
|
<!--
|
||||||
|
|
@ -112,6 +113,7 @@
|
||||||
android:name=".MainApp"
|
android:name=".MainApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/backup_rules"
|
android:dataExtractionRules="@xml/backup_rules"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:fullBackupContent="@xml/backup_config"
|
android:fullBackupContent="@xml/backup_config"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:installLocation="internalOnly"
|
android:installLocation="internalOnly"
|
||||||
|
|
@ -121,13 +123,13 @@
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:enableOnBackInvokedCallback="false"
|
|
||||||
android:theme="@style/Theme.ownCloud.Toolbar"
|
android:theme="@style/Theme.ownCloud.Toolbar"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:ignore="UnusedAttribute"
|
tools:ignore="UnusedAttribute"
|
||||||
tools:replace="android:allowBackup">
|
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" />
|
android:resource="@xml/app_config" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|
@ -159,7 +161,9 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:host="*" />
|
<data
|
||||||
|
android:host="*"
|
||||||
|
tools:ignore="AppLinkUrlError" />
|
||||||
<data android:pathPattern="/f/..*" />
|
<data android:pathPattern="/f/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
|
|
@ -169,7 +173,9 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:host="*" />
|
<data
|
||||||
|
android:host="*"
|
||||||
|
tools:ignore="AppLinkUrlError" />
|
||||||
<data android:pathPattern="/..*/f/..*" />
|
<data android:pathPattern="/..*/f/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
|
|
@ -179,7 +185,9 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:host="*" />
|
<data
|
||||||
|
android:host="*"
|
||||||
|
tools:ignore="AppLinkUrlError" />
|
||||||
<data android:pathPattern="/..*/..*/f/..*" />
|
<data android:pathPattern="/..*/..*/f/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
|
|
@ -189,7 +197,9 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:host="*" />
|
<data
|
||||||
|
android:host="*"
|
||||||
|
tools:ignore="AppLinkUrlError" />
|
||||||
<data android:pathPattern="/..*/..*/..*/f/..*" />
|
<data android:pathPattern="/..*/..*/..*/f/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
|
|
@ -199,7 +209,9 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="*" />
|
<data
|
||||||
|
android:host="*"
|
||||||
|
tools:ignore="AppLinkUrlError" />
|
||||||
<data android:pathPattern="/f/..*" />
|
<data android:pathPattern="/f/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
|
|
@ -209,7 +221,9 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="*" />
|
<data
|
||||||
|
android:host="*"
|
||||||
|
tools:ignore="AppLinkUrlError" />
|
||||||
<data android:pathPattern="/..*/f/..*" />
|
<data android:pathPattern="/..*/f/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
|
|
@ -219,7 +233,9 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="*" />
|
<data
|
||||||
|
android:host="*"
|
||||||
|
tools:ignore="AppLinkUrlError" />
|
||||||
<data android:pathPattern="/..*/..*/f/..*" />
|
<data android:pathPattern="/..*/..*/f/..*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
|
|
@ -229,7 +245,9 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="*" />
|
<data
|
||||||
|
android:host="*"
|
||||||
|
tools:ignore="AppLinkUrlError" />
|
||||||
<data android:pathPattern="/..*/..*/..*/f/..*" />
|
<data android:pathPattern="/..*/..*/..*/f/..*" />
|
||||||
<!-- path pattern to handle deep link -->
|
<!-- path pattern to handle deep link -->
|
||||||
<data android:pathPattern="/app/..*" />
|
<data android:pathPattern="/app/..*" />
|
||||||
|
|
@ -352,6 +370,7 @@
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.activity.SettingsActivity"
|
android:name=".ui.activity.SettingsActivity"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@style/PreferenceTheme" />
|
android:theme="@style/PreferenceTheme" />
|
||||||
<activity
|
<activity
|
||||||
|
|
@ -366,11 +385,11 @@
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="com.nextcloud.client.media.BackgroundPlayerService"
|
android:name="com.nextcloud.client.media.BackgroundPlayerService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="mediaPlayback"
|
||||||
tools:ignore="ExportedService">
|
tools:ignore="ExportedService">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
|
@ -584,6 +603,10 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name="com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.activity.CopyToClipboardActivity"
|
android:name=".ui.activity.CopyToClipboardActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -11,18 +11,31 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.nextcloud.client.assistant.model.ScreenOverlayState
|
import com.nextcloud.client.assistant.model.ScreenOverlayState
|
||||||
import com.nextcloud.client.assistant.model.ScreenState
|
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.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.Task
|
||||||
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
|
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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)
|
private val _screenState = MutableStateFlow<ScreenState?>(null)
|
||||||
val screenState: StateFlow<ScreenState?> = _screenState
|
val screenState: StateFlow<ScreenState?> = _screenState
|
||||||
|
|
@ -44,14 +57,54 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
||||||
private val _filteredTaskList = MutableStateFlow<List<Task>?>(null)
|
private val _filteredTaskList = MutableStateFlow<List<Task>?>(null)
|
||||||
val filteredTaskList: StateFlow<List<Task>?> = _filteredTaskList
|
val filteredTaskList: StateFlow<List<Task>?> = _filteredTaskList
|
||||||
|
|
||||||
|
private var taskPollingJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
fetchTaskTypes()
|
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")
|
@Suppress("MagicNumber")
|
||||||
fun createTask(input: String, taskType: TaskTypeData) {
|
fun createTask(input: String, taskType: TaskTypeData) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val result = repository.createTask(input, taskType)
|
val result = remoteRepository.createTask(input, taskType)
|
||||||
|
|
||||||
val messageId = if (result.isSuccess) {
|
val messageId = if (result.isSuccess) {
|
||||||
R.string.assistant_screen_task_create_success_message
|
R.string.assistant_screen_task_create_success_message
|
||||||
|
|
@ -76,15 +129,11 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
||||||
|
|
||||||
private fun fetchTaskTypes() {
|
private fun fetchTaskTypes() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val taskTypesResult = repository.getTaskTypes()
|
val taskTypesResult = remoteRepository.getTaskTypes()
|
||||||
|
if (taskTypesResult == null || taskTypesResult.isEmpty()) {
|
||||||
if (taskTypesResult == null) {
|
_screenState.update {
|
||||||
updateSnackbarMessage(R.string.assistant_screen_task_types_error_state_message)
|
ScreenState.emptyTaskTypes()
|
||||||
return@launch
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (taskTypesResult.isEmpty()) {
|
|
||||||
updateSnackbarMessage(R.string.assistant_screen_task_list_empty_message)
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,12 +147,17 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
||||||
|
|
||||||
fun fetchTaskList() {
|
fun fetchTaskList() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
_screenState.update {
|
// Try cached data first
|
||||||
ScreenState.Refreshing
|
val cachedTasks = localRepository.getCachedTasks(accountName)
|
||||||
|
if (cachedTasks.isNotEmpty()) {
|
||||||
|
_filteredTaskList.update {
|
||||||
|
cachedTasks.sortedByDescending { it.id }
|
||||||
|
}
|
||||||
|
updateTaskListScreenState()
|
||||||
}
|
}
|
||||||
|
|
||||||
val taskType = _selectedTaskType.value?.id ?: return@launch
|
val taskType = _selectedTaskType.value?.id ?: return@launch
|
||||||
val result = repository.getTaskList(taskType)
|
val result = remoteRepository.getTaskList(taskType)
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
taskList = result
|
taskList = result
|
||||||
_filteredTaskList.update {
|
_filteredTaskList.update {
|
||||||
|
|
@ -111,19 +165,21 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
||||||
task.id
|
task.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localRepository.cacheTasks(result, accountName)
|
||||||
updateSnackbarMessage(null)
|
updateSnackbarMessage(null)
|
||||||
} else {
|
} else {
|
||||||
updateSnackbarMessage(R.string.assistant_screen_task_list_error_state_message)
|
updateSnackbarMessage(R.string.assistant_screen_task_list_error_state_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateScreenState()
|
updateTaskListScreenState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateScreenState() {
|
private fun updateTaskListScreenState() {
|
||||||
_screenState.update {
|
_screenState.update {
|
||||||
if (_filteredTaskList.value?.isEmpty() == true) {
|
if (_filteredTaskList.value?.isEmpty() == true) {
|
||||||
ScreenState.EmptyContent
|
ScreenState.emptyTaskList()
|
||||||
} else {
|
} else {
|
||||||
ScreenState.Content
|
ScreenState.Content
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +188,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
||||||
|
|
||||||
fun deleteTask(id: Long) {
|
fun deleteTask(id: Long) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val result = repository.deleteTask(id)
|
val result = remoteRepository.deleteTask(id)
|
||||||
|
|
||||||
val messageId = if (result.isSuccess) {
|
val messageId = if (result.isSuccess) {
|
||||||
R.string.assistant_screen_task_delete_success_message
|
R.string.assistant_screen_task_delete_success_message
|
||||||
|
|
@ -144,6 +200,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
removeTaskFromList(id)
|
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 {
|
_screenOverlayState.update {
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,24 @@
|
||||||
|
|
||||||
package com.nextcloud.client.assistant.model
|
package com.nextcloud.client.assistant.model
|
||||||
|
|
||||||
enum class ScreenState {
|
import com.owncloud.android.R
|
||||||
Refreshing,
|
|
||||||
EmptyContent,
|
sealed class ScreenState {
|
||||||
Content
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
package com.nextcloud.client.assistant.taskDetail
|
package com.nextcloud.client.assistant.taskDetail
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
@ -28,9 +32,11 @@ import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
@ -54,29 +60,54 @@ fun TaskDetailBottomSheet(task: Task, showTaskActions: () -> Unit, dismiss: () -
|
||||||
onDismissRequest = { dismiss() },
|
onDismissRequest = { dismiss() },
|
||||||
sheetState = sheetState
|
sheetState = sheetState
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
Box {
|
||||||
modifier = Modifier
|
LazyColumn(
|
||||||
.fillMaxSize()
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.fillMaxSize()
|
||||||
) {
|
.padding(16.dp)
|
||||||
stickyHeader {
|
) {
|
||||||
Row(
|
stickyHeader {
|
||||||
modifier = Modifier.fillMaxWidth()
|
Row(
|
||||||
) {
|
modifier = Modifier.fillMaxWidth()
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
IconButton(onClick = showTaskActions) {
|
IconButton(onClick = showTaskActions) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.MoreVert,
|
imageVector = Icons.Filled.MoreVert,
|
||||||
contentDescription = "More button",
|
contentDescription = "More button",
|
||||||
tint = colorResource(R.color.text_color)
|
tint = colorResource(R.color.text_color)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
InputOutputCard(task)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
Row(
|
||||||
InputOutputCard(task)
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,9 @@
|
||||||
package com.nextcloud.client.assistant.taskTypes
|
package com.nextcloud.client.assistant.taskTypes
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRowDefaults
|
import androidx.compose.material3.TabRowDefaults
|
||||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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) {
|
fun TaskTypesRow(selectedTaskType: TaskTypeData?, data: List<TaskTypeData>, selectTaskType: (TaskTypeData) -> Unit) {
|
||||||
val selectedTabIndex = data.indexOfFirst { it.id == selectedTaskType?.id }.takeIf { it >= 0 } ?: 0
|
val selectedTabIndex = data.indexOfFirst { it.id == selectedTaskType?.id }.takeIf { it >= 0 } ?: 0
|
||||||
|
|
||||||
ScrollableTabRow(
|
PrimaryScrollableTabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
edgePadding = 0.dp,
|
edgePadding = 0.dp,
|
||||||
containerColor = colorResource(R.color.actionbar_color),
|
containerColor = colorResource(R.color.actionbar_color),
|
||||||
indicator = {
|
indicator = {
|
||||||
TabRowDefaults.SecondaryIndicator(
|
TabRowDefaults.SecondaryIndicator(
|
||||||
Modifier.tabIndicatorOffset(it[selectedTabIndex]),
|
Modifier.tabIndicatorOffset(selectedTabIndex),
|
||||||
color = colorResource(R.color.primary)
|
color = colorResource(R.color.primary)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,15 @@ import androidx.room.TypeConverters
|
||||||
import com.nextcloud.client.core.Clock
|
import com.nextcloud.client.core.Clock
|
||||||
import com.nextcloud.client.core.ClockImpl
|
import com.nextcloud.client.core.ClockImpl
|
||||||
import com.nextcloud.client.database.dao.ArbitraryDataDao
|
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.FileDao
|
||||||
|
import com.nextcloud.client.database.dao.FileSystemDao
|
||||||
import com.nextcloud.client.database.dao.OfflineOperationDao
|
import com.nextcloud.client.database.dao.OfflineOperationDao
|
||||||
import com.nextcloud.client.database.dao.RecommendedFileDao
|
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.dao.UploadDao
|
||||||
import com.nextcloud.client.database.entity.ArbitraryDataEntity
|
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.CapabilityEntity
|
||||||
import com.nextcloud.client.database.entity.ExternalLinkEntity
|
import com.nextcloud.client.database.entity.ExternalLinkEntity
|
||||||
import com.nextcloud.client.database.entity.FileEntity
|
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.RoomMigration
|
||||||
import com.nextcloud.client.database.migrations.addLegacyMigrations
|
import com.nextcloud.client.database.migrations.addLegacyMigrations
|
||||||
import com.nextcloud.client.database.typeConverter.OfflineOperationTypeConverter
|
import com.nextcloud.client.database.typeConverter.OfflineOperationTypeConverter
|
||||||
|
import com.owncloud.android.MainApp
|
||||||
import com.owncloud.android.db.ProviderMeta
|
import com.owncloud.android.db.ProviderMeta
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
|
|
@ -51,7 +56,8 @@ import com.owncloud.android.db.ProviderMeta
|
||||||
UploadEntity::class,
|
UploadEntity::class,
|
||||||
VirtualEntity::class,
|
VirtualEntity::class,
|
||||||
OfflineOperationEntity::class,
|
OfflineOperationEntity::class,
|
||||||
RecommendedFileEntity::class
|
RecommendedFileEntity::class,
|
||||||
|
AssistantEntity::class
|
||||||
],
|
],
|
||||||
version = ProviderMeta.DB_VERSION,
|
version = ProviderMeta.DB_VERSION,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
|
|
@ -81,7 +87,10 @@ import com.owncloud.android.db.ProviderMeta
|
||||||
AutoMigration(from = 89, to = 90),
|
AutoMigration(from = 89, to = 90),
|
||||||
AutoMigration(from = 90, to = 91),
|
AutoMigration(from = 90, to = 91),
|
||||||
AutoMigration(from = 91, to = 92),
|
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
|
exportSchema = true
|
||||||
)
|
)
|
||||||
|
|
@ -94,6 +103,9 @@ abstract class NextcloudDatabase : RoomDatabase() {
|
||||||
abstract fun offlineOperationDao(): OfflineOperationDao
|
abstract fun offlineOperationDao(): OfflineOperationDao
|
||||||
abstract fun uploadDao(): UploadDao
|
abstract fun uploadDao(): UploadDao
|
||||||
abstract fun recommendedFileDao(): RecommendedFileDao
|
abstract fun recommendedFileDao(): RecommendedFileDao
|
||||||
|
abstract fun fileSystemDao(): FileSystemDao
|
||||||
|
abstract fun syncedFolderDao(): SyncedFolderDao
|
||||||
|
abstract fun assistantDao(): AssistantDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val FIRST_ROOM_DB_VERSION = 65
|
const val FIRST_ROOM_DB_VERSION = 65
|
||||||
|
|
@ -119,5 +131,9 @@ abstract class NextcloudDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
return instance!!
|
return instance!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@JvmStatic
|
||||||
|
fun instance(): NextcloudDatabase = getInstance(MainApp.getAppContext())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -17,15 +17,6 @@ import com.owncloud.android.utils.MimeType
|
||||||
@Suppress("TooManyFunctions")
|
@Suppress("TooManyFunctions")
|
||||||
@Dao
|
@Dao
|
||||||
interface FileDao {
|
interface FileDao {
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT DISTINCT parent
|
|
||||||
FROM filelist
|
|
||||||
WHERE path IN (:subfilePaths)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun getParentIdsOfSubfiles(subfilePaths: List<String>): List<Long>
|
|
||||||
|
|
||||||
@Update
|
@Update
|
||||||
fun update(entity: FileEntity)
|
fun update(entity: FileEntity)
|
||||||
|
|
||||||
|
|
@ -108,4 +99,16 @@ interface FileDao {
|
||||||
dirType: String = MimeType.DIRECTORY,
|
dirType: String = MimeType.DIRECTORY,
|
||||||
webdavType: String = MimeType.WEBDAV_FOLDER
|
webdavType: String = MimeType.WEBDAV_FOLDER
|
||||||
): List<FileEntity>
|
): 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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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?
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
package com.nextcloud.client.database.dao
|
package com.nextcloud.client.database.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.nextcloud.client.database.entity.UploadEntity
|
import com.nextcloud.client.database.entity.UploadEntity
|
||||||
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
|
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
|
||||||
|
|
@ -27,4 +29,68 @@ interface UploadDao {
|
||||||
ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName"
|
ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName"
|
||||||
)
|
)
|
||||||
fun getUploadsByIds(ids: LongArray, accountName: String): List<UploadEntity>
|
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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -142,5 +142,9 @@ data class CapabilityEntity(
|
||||||
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS)
|
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS)
|
||||||
val defaultPermissions: Int?,
|
val defaultPermissions: Int?,
|
||||||
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_BUSY)
|
@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?
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ package com.nextcloud.client.database.entity
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
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
|
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
|
||||||
|
|
||||||
@Entity(tableName = ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME)
|
@Entity(tableName = ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME)
|
||||||
|
|
@ -50,3 +53,40 @@ data class SyncedFolderEntity(
|
||||||
@ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_LAST_SCAN_TIMESTAMP_MS)
|
@ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_LAST_SCAN_TIMESTAMP_MS)
|
||||||
val lastScanTimestampMs: Long?
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/*
|
/*
|
||||||
* Nextcloud - Android Client
|
* Nextcloud - Android Client
|
||||||
*
|
*
|
||||||
|
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
|
||||||
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
|
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
|
||||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
|
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
|
* 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
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ package com.nextcloud.client.device
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import com.nextcloud.client.preferences.AppPreferences
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
|
||||||
|
|
@ -18,13 +17,11 @@ import dagger.Provides
|
||||||
class DeviceModule {
|
class DeviceModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun powerManagementService(context: Context, preferences: AppPreferences): PowerManagementService {
|
fun powerManagementService(context: Context): PowerManagementService {
|
||||||
val platformPowerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
val platformPowerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
return PowerManagementServiceImpl(
|
return PowerManagementServiceImpl(
|
||||||
context = context,
|
context = context,
|
||||||
platformPowerManager = platformPowerManager,
|
platformPowerManager = platformPowerManager
|
||||||
deviceInfo = DeviceInfo(),
|
|
||||||
preferences = preferences
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,6 @@ interface PowerManagementService {
|
||||||
*/
|
*/
|
||||||
val isPowerSavingEnabled: Boolean
|
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]
|
* Checks current battery status using platform [android.os.BatteryManager]
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -11,46 +11,27 @@ import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.os.BatteryManager
|
import android.os.BatteryManager
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import com.nextcloud.client.preferences.AppPreferences
|
|
||||||
import com.nextcloud.client.preferences.AppPreferencesImpl
|
|
||||||
import com.nextcloud.utils.extensions.registerBroadcastReceiver
|
import com.nextcloud.utils.extensions.registerBroadcastReceiver
|
||||||
import com.owncloud.android.datamodel.ReceiverFlag
|
import com.owncloud.android.datamodel.ReceiverFlag
|
||||||
|
|
||||||
internal class PowerManagementServiceImpl(
|
internal class PowerManagementServiceImpl(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val platformPowerManager: PowerManager,
|
private val platformPowerManager: PowerManager
|
||||||
private val preferences: AppPreferences,
|
|
||||||
private val deviceInfo: DeviceInfo = DeviceInfo()
|
|
||||||
) : PowerManagementService {
|
) : PowerManagementService {
|
||||||
|
|
||||||
companion object {
|
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
|
@JvmStatic
|
||||||
fun fromContext(context: Context): PowerManagementServiceImpl {
|
fun fromContext(context: Context): PowerManagementServiceImpl {
|
||||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
val preferences = AppPreferencesImpl.fromContext(context)
|
return PowerManagementServiceImpl(context, powerManager)
|
||||||
|
|
||||||
return PowerManagementServiceImpl(context, powerManager, preferences, DeviceInfo())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val isPowerSavingEnabled: Boolean
|
override val isPowerSavingEnabled: Boolean
|
||||||
get() {
|
get() {
|
||||||
if (preferences.isPowerCheckDisabled) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return platformPowerManager.isPowerSaveMode
|
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
|
@Suppress("MagicNumber") // 100% is 100, we're not doing Cobol
|
||||||
override val battery: BatteryStatus
|
override val battery: BatteryStatus
|
||||||
get() {
|
get() {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import com.nextcloud.client.integrations.IntegrationsModule;
|
||||||
import com.nextcloud.client.jobs.JobsModule;
|
import com.nextcloud.client.jobs.JobsModule;
|
||||||
import com.nextcloud.client.jobs.download.FileDownloadHelper;
|
import com.nextcloud.client.jobs.download.FileDownloadHelper;
|
||||||
import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver;
|
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.FileUploadBroadcastReceiver;
|
||||||
import com.nextcloud.client.jobs.upload.FileUploadHelper;
|
import com.nextcloud.client.jobs.upload.FileUploadHelper;
|
||||||
import com.nextcloud.client.media.BackgroundPlayerService;
|
import com.nextcloud.client.media.BackgroundPlayerService;
|
||||||
|
|
@ -75,6 +76,8 @@ public interface AppComponent {
|
||||||
|
|
||||||
void inject(OfflineOperationReceiver offlineOperationReceiver);
|
void inject(OfflineOperationReceiver offlineOperationReceiver);
|
||||||
|
|
||||||
|
void inject(FolderDownloadWorkerReceiver folderDownloadWorkerReceiver);
|
||||||
|
|
||||||
@Component.Builder
|
@Component.Builder
|
||||||
interface Builder {
|
interface Builder {
|
||||||
@BindsInstance
|
@BindsInstance
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ class DocumentScanActivity :
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
android.R.id.home -> {
|
android.R.id.home -> {
|
||||||
onBackPressed()
|
onBackPressedDispatcher.onBackPressed()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.nextcloud.client.di.Injectable
|
import com.nextcloud.client.di.Injectable
|
||||||
|
|
@ -46,6 +47,7 @@ class EtmActivity :
|
||||||
onPageChanged(it)
|
onPageChanged(it)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
handleOnBackPressed()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||||
|
|
@ -58,11 +60,17 @@ class EtmActivity :
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
private fun handleOnBackPressed() {
|
||||||
override fun onBackPressed() {
|
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
|
||||||
if (!vm.onBackPressed()) {
|
override fun handleOnBackPressed() {
|
||||||
super.onBackPressed()
|
val handledByVm = vm.onBackPressed()
|
||||||
}
|
|
||||||
|
if (!handledByVm) {
|
||||||
|
isEnabled = false
|
||||||
|
onBackPressedDispatcher.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPageChanged(page: EtmMenuEntry?) {
|
private fun onPageChanged(page: EtmMenuEntry?) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
package com.nextcloud.client.etm
|
package com.nextcloud.client.etm
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
|
@ -20,6 +21,7 @@ class EtmMenuAdapter(context: Context, val onItemClicked: (Int) -> Unit) :
|
||||||
|
|
||||||
private val layoutInflater = LayoutInflater.from(context)
|
private val layoutInflater = LayoutInflater.from(context)
|
||||||
var pages: List<EtmMenuEntry> = listOf()
|
var pages: List<EtmMenuEntry> = listOf()
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
package com.nextcloud.client.etm.pages
|
package com.nextcloud.client.etm.pages
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
|
|
@ -63,6 +64,7 @@ class EtmFileTransferFragment : EtmBaseFragment() {
|
||||||
|
|
||||||
private var transfers = listOf<Transfer>()
|
private var transfers = listOf<Transfer>()
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
fun setStatus(status: TransferManager.Status) {
|
fun setStatus(status: TransferManager.Status) {
|
||||||
transfers = listOf(status.pending, status.running, status.completed).flatten().reversed()
|
transfers = listOf(status.pending, status.running, status.completed).flatten().reversed()
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,17 @@ import androidx.work.WorkerFactory
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.nextcloud.client.account.UserAccountManager
|
import com.nextcloud.client.account.UserAccountManager
|
||||||
import com.nextcloud.client.core.Clock
|
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.device.PowerManagementService
|
||||||
import com.nextcloud.client.documentscan.GeneratePDFUseCase
|
import com.nextcloud.client.documentscan.GeneratePDFUseCase
|
||||||
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
|
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
|
||||||
import com.nextcloud.client.integrations.deck.DeckApi
|
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.download.FileDownloadWorker
|
||||||
import com.nextcloud.client.jobs.metadata.MetadataWorker
|
import com.nextcloud.client.jobs.metadata.MetadataWorker
|
||||||
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
|
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.jobs.upload.FileUploadWorker
|
||||||
import com.nextcloud.client.logger.Logger
|
import com.nextcloud.client.logger.Logger
|
||||||
import com.nextcloud.client.network.ConnectivityService
|
import com.nextcloud.client.network.ConnectivityService
|
||||||
|
|
@ -50,7 +53,6 @@ class BackgroundJobFactory @Inject constructor(
|
||||||
private val clock: Clock,
|
private val clock: Clock,
|
||||||
private val powerManagementService: PowerManagementService,
|
private val powerManagementService: PowerManagementService,
|
||||||
private val backgroundJobManager: Provider<BackgroundJobManager>,
|
private val backgroundJobManager: Provider<BackgroundJobManager>,
|
||||||
private val deviceInfo: DeviceInfo,
|
|
||||||
private val accountManager: UserAccountManager,
|
private val accountManager: UserAccountManager,
|
||||||
private val resources: Resources,
|
private val resources: Resources,
|
||||||
private val arbitraryDataProvider: ArbitraryDataProvider,
|
private val arbitraryDataProvider: ArbitraryDataProvider,
|
||||||
|
|
@ -62,7 +64,8 @@ class BackgroundJobFactory @Inject constructor(
|
||||||
private val viewThemeUtils: Provider<ViewThemeUtils>,
|
private val viewThemeUtils: Provider<ViewThemeUtils>,
|
||||||
private val localBroadcastManager: Provider<LocalBroadcastManager>,
|
private val localBroadcastManager: Provider<LocalBroadcastManager>,
|
||||||
private val generatePdfUseCase: GeneratePDFUseCase,
|
private val generatePdfUseCase: GeneratePDFUseCase,
|
||||||
private val syncedFolderProvider: SyncedFolderProvider
|
private val syncedFolderProvider: SyncedFolderProvider,
|
||||||
|
private val database: NextcloudDatabase
|
||||||
) : WorkerFactory() {
|
) : WorkerFactory() {
|
||||||
|
|
||||||
@SuppressLint("NewApi")
|
@SuppressLint("NewApi")
|
||||||
|
|
@ -84,7 +87,7 @@ class BackgroundJobFactory @Inject constructor(
|
||||||
when (workerClass) {
|
when (workerClass) {
|
||||||
ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters)
|
ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters)
|
||||||
ContactsImportWork::class -> createContactsImportWork(context, workerParameters)
|
ContactsImportWork::class -> createContactsImportWork(context, workerParameters)
|
||||||
FilesSyncWork::class -> createFilesSyncWork(context, workerParameters)
|
AutoUploadWorker::class -> createFilesSyncWork(context, workerParameters)
|
||||||
OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters)
|
OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters)
|
||||||
MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters)
|
MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters)
|
||||||
NotificationWork::class -> createNotificationWork(context, workerParameters)
|
NotificationWork::class -> createNotificationWork(context, workerParameters)
|
||||||
|
|
@ -100,6 +103,7 @@ class BackgroundJobFactory @Inject constructor(
|
||||||
OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters)
|
OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters)
|
||||||
InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters)
|
InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters)
|
||||||
MetadataWorker::class -> createMetadataWorker(context, workerParameters)
|
MetadataWorker::class -> createMetadataWorker(context, workerParameters)
|
||||||
|
FolderDownloadWorker::class -> createFolderDownloadWorker(context, workerParameters)
|
||||||
else -> null // caller falls back to default factory
|
else -> null // caller falls back to default factory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,16 +170,16 @@ class BackgroundJobFactory @Inject constructor(
|
||||||
contentResolver
|
contentResolver
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createFilesSyncWork(context: Context, params: WorkerParameters): FilesSyncWork = FilesSyncWork(
|
private fun createFilesSyncWork(context: Context, params: WorkerParameters): AutoUploadWorker = AutoUploadWorker(
|
||||||
context = context,
|
context = context,
|
||||||
params = params,
|
params = params,
|
||||||
contentResolver = contentResolver,
|
|
||||||
userAccountManager = accountManager,
|
userAccountManager = accountManager,
|
||||||
uploadsStorageManager = uploadsStorageManager,
|
uploadsStorageManager = uploadsStorageManager,
|
||||||
connectivityService = connectivityService,
|
connectivityService = connectivityService,
|
||||||
powerManagementService = powerManagementService,
|
powerManagementService = powerManagementService,
|
||||||
syncedFolderProvider = syncedFolderProvider,
|
syncedFolderProvider = syncedFolderProvider,
|
||||||
backgroundJobManager = backgroundJobManager.get()
|
backgroundJobManager = backgroundJobManager.get(),
|
||||||
|
repository = FileSystemRepository(dao = database.fileSystemDao())
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork = OfflineSyncWork(
|
private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork = OfflineSyncWork(
|
||||||
|
|
@ -285,4 +289,12 @@ class BackgroundJobFactory @Inject constructor(
|
||||||
params,
|
params,
|
||||||
accountManager.user
|
accountManager.user
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun createFolderDownloadWorker(context: Context, params: WorkerParameters): FolderDownloadWorker =
|
||||||
|
FolderDownloadWorker(
|
||||||
|
accountManager,
|
||||||
|
context,
|
||||||
|
viewThemeUtils.get(),
|
||||||
|
params
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.lifecycle.LiveData
|
||||||
import androidx.work.ListenableWorker
|
import androidx.work.ListenableWorker
|
||||||
import com.nextcloud.client.account.User
|
import com.nextcloud.client.account.User
|
||||||
import com.owncloud.android.datamodel.OCFile
|
import com.owncloud.android.datamodel.OCFile
|
||||||
|
import com.owncloud.android.datamodel.SyncedFolder
|
||||||
import com.owncloud.android.operations.DownloadType
|
import com.owncloud.android.operations.DownloadType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -119,15 +120,12 @@ interface BackgroundJobManager {
|
||||||
|
|
||||||
fun startImmediateFilesExportJob(files: Collection<OCFile>): LiveData<JobInfo?>
|
fun startImmediateFilesExportJob(files: Collection<OCFile>): LiveData<JobInfo?>
|
||||||
|
|
||||||
fun schedulePeriodicFilesSyncJob(syncedFolderID: Long)
|
fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder)
|
||||||
|
|
||||||
/**
|
fun startAutoUploadImmediately(
|
||||||
* Immediately start File Sync job for given syncFolderID.
|
syncedFolder: SyncedFolder,
|
||||||
*/
|
|
||||||
fun startImmediateFilesSyncJob(
|
|
||||||
syncedFolderID: Long,
|
|
||||||
overridePowerSaving: Boolean = false,
|
overridePowerSaving: Boolean = false,
|
||||||
changedFiles: Array<String?> = arrayOf<String?>()
|
contentUris: Array<String?> = arrayOf()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun cancelTwoWaySyncJob()
|
fun cancelTwoWaySyncJob()
|
||||||
|
|
@ -142,12 +140,10 @@ interface BackgroundJobManager {
|
||||||
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
|
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
|
||||||
fun getFileUploads(user: User): LiveData<List<JobInfo>>
|
fun getFileUploads(user: User): LiveData<List<JobInfo>>
|
||||||
fun cancelFilesUploadJob(user: User)
|
fun cancelFilesUploadJob(user: User)
|
||||||
fun isStartFileUploadJobScheduled(user: User): Boolean
|
fun isStartFileUploadJobScheduled(accountName: String): Boolean
|
||||||
|
|
||||||
fun cancelFilesDownloadJob(user: User, fileId: Long)
|
fun cancelFilesDownloadJob(user: User, fileId: Long)
|
||||||
|
|
||||||
fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean
|
|
||||||
|
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
fun startFileDownloadJob(
|
fun startFileDownloadJob(
|
||||||
user: User,
|
user: User,
|
||||||
|
|
@ -175,4 +171,6 @@ interface BackgroundJobManager {
|
||||||
fun scheduleInternal2WaySync(intervalMinutes: Long)
|
fun scheduleInternal2WaySync(intervalMinutes: Long)
|
||||||
fun cancelAllFilesDownloadJobs()
|
fun cancelAllFilesDownloadJobs()
|
||||||
fun startMetadataSyncJob(currentDirPath: String)
|
fun startMetadataSyncJob(currentDirPath: String)
|
||||||
|
fun downloadFolder(folder: OCFile, accountName: String)
|
||||||
|
fun cancelFolderDownload()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ import com.nextcloud.client.account.User
|
||||||
import com.nextcloud.client.core.Clock
|
import com.nextcloud.client.core.Clock
|
||||||
import com.nextcloud.client.di.Injectable
|
import com.nextcloud.client.di.Injectable
|
||||||
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
|
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.download.FileDownloadWorker
|
||||||
|
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
|
||||||
import com.nextcloud.client.jobs.metadata.MetadataWorker
|
import com.nextcloud.client.jobs.metadata.MetadataWorker
|
||||||
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
|
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
|
||||||
import com.nextcloud.client.jobs.upload.FileUploadHelper
|
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.isWorkRunning
|
||||||
import com.nextcloud.utils.extensions.isWorkScheduled
|
import com.nextcloud.utils.extensions.isWorkScheduled
|
||||||
import com.owncloud.android.datamodel.OCFile
|
import com.owncloud.android.datamodel.OCFile
|
||||||
|
import com.owncloud.android.datamodel.SyncedFolder
|
||||||
import com.owncloud.android.operations.DownloadType
|
import com.owncloud.android.operations.DownloadType
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -91,6 +94,7 @@ internal class BackgroundJobManagerImpl(
|
||||||
const val JOB_PERIODIC_OFFLINE_OPERATIONS = "periodic_offline_operations"
|
const val JOB_PERIODIC_OFFLINE_OPERATIONS = "periodic_offline_operations"
|
||||||
const val JOB_PERIODIC_HEALTH_STATUS = "periodic_health_status"
|
const val JOB_PERIODIC_HEALTH_STATUS = "periodic_health_status"
|
||||||
const val JOB_IMMEDIATE_HEALTH_STATUS = "immediate_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_METADATA_SYNC = "metadata_sync"
|
||||||
const val JOB_INTERNAL_TWO_WAY_SYNC = "internal_two_way_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()
|
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()
|
.build()
|
||||||
|
|
||||||
val request = periodicRequestBuilder(
|
val request = periodicRequestBuilder(
|
||||||
jobClass = FilesSyncWork::class,
|
jobClass = AutoUploadWorker::class,
|
||||||
jobName = JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
|
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)
|
.setInputData(arguments)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(
|
workManager.enqueueUniquePeriodicWork(
|
||||||
JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
|
JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
|
||||||
ExistingPeriodicWorkPolicy.REPLACE,
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startImmediateFilesSyncJob(
|
override fun startAutoUploadImmediately(
|
||||||
syncedFolderID: Long,
|
syncedFolder: SyncedFolder,
|
||||||
overridePowerSaving: Boolean,
|
overridePowerSaving: Boolean,
|
||||||
changedFiles: Array<String?>
|
contentUris: Array<String?>
|
||||||
) {
|
) {
|
||||||
|
val syncedFolderID = syncedFolder.id
|
||||||
|
|
||||||
val arguments = Data.Builder()
|
val arguments = Data.Builder()
|
||||||
.putBoolean(FilesSyncWork.OVERRIDE_POWER_SAVING, overridePowerSaving)
|
.putBoolean(AutoUploadWorker.OVERRIDE_POWER_SAVING, overridePowerSaving)
|
||||||
.putStringArray(FilesSyncWork.CHANGED_FILES, changedFiles)
|
.putStringArray(AutoUploadWorker.CONTENT_URIS, contentUris)
|
||||||
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
|
.putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.setRequiresCharging(syncedFolder.isChargingOnly)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val request = oneTimeRequestBuilder(
|
val request = oneTimeRequestBuilder(
|
||||||
jobClass = FilesSyncWork::class,
|
jobClass = AutoUploadWorker::class,
|
||||||
jobName = JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID
|
jobName = JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID
|
||||||
)
|
)
|
||||||
.setInputData(arguments)
|
.setInputData(arguments)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(
|
||||||
|
BackoffPolicy.LINEAR,
|
||||||
|
DEFAULT_BACKOFF_CRITERIA_DELAY_SEC,
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
workManager.enqueueUniqueWork(
|
workManager.enqueueUniqueWork(
|
||||||
|
|
@ -606,10 +637,10 @@ internal class BackgroundJobManagerImpl(
|
||||||
workManager.enqueue(request)
|
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 =
|
override fun isStartFileUploadJobScheduled(accountName: String): Boolean =
|
||||||
workManager.isWorkScheduled(startFileUploadJobTag(user))
|
workManager.isWorkScheduled(startFileUploadJobTag(accountName))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method supports initiating uploads for various scenarios, including:
|
* This method supports initiating uploads for various scenarios, including:
|
||||||
|
|
@ -627,7 +658,7 @@ internal class BackgroundJobManagerImpl(
|
||||||
defaultDispatcherScope.launch {
|
defaultDispatcherScope.launch {
|
||||||
val batchSize = FileUploadHelper.MAX_FILE_COUNT
|
val batchSize = FileUploadHelper.MAX_FILE_COUNT
|
||||||
val batches = uploadIds.toList().chunked(batchSize)
|
val batches = uploadIds.toList().chunked(batchSize)
|
||||||
val tag = startFileUploadJobTag(user)
|
val tag = startFileUploadJobTag(user.accountName)
|
||||||
|
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
|
@ -673,9 +704,6 @@ internal class BackgroundJobManagerImpl(
|
||||||
private fun startFileDownloadJobTag(user: User, fileId: Long): String =
|
private fun startFileDownloadJobTag(user: User, fileId: Long): String =
|
||||||
JOB_FOLDER_DOWNLOAD + user.accountName + fileId
|
JOB_FOLDER_DOWNLOAD + user.accountName + fileId
|
||||||
|
|
||||||
override fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean =
|
|
||||||
workManager.isWorkScheduled(startFileDownloadJobTag(user, fileId))
|
|
||||||
|
|
||||||
override fun startFileDownloadJob(
|
override fun startFileDownloadJob(
|
||||||
user: User,
|
user: User,
|
||||||
file: OCFile,
|
file: OCFile,
|
||||||
|
|
@ -795,4 +823,28 @@ internal class BackgroundJobManagerImpl(
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
/*
|
/*
|
||||||
* Nextcloud - Android Client
|
* Nextcloud - Android Client
|
||||||
*
|
*
|
||||||
|
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
|
||||||
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
|
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
|
||||||
*/
|
*/
|
||||||
package com.nextcloud.client.jobs
|
package com.nextcloud.client.jobs
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.work.Worker
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.nextcloud.client.device.PowerManagementService
|
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.datamodel.SyncedFolderProvider
|
||||||
import com.owncloud.android.lib.common.utils.Log_OC
|
import com.owncloud.android.lib.common.utils.Log_OC
|
||||||
|
import com.owncloud.android.ui.notifications.NotificationUtils
|
||||||
import com.owncloud.android.utils.FilesSyncHelper
|
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.
|
* 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.
|
* This job must not be started on API < 24.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
class ContentObserverWork(
|
class ContentObserverWork(
|
||||||
appContext: Context,
|
private val context: Context,
|
||||||
private val params: WorkerParameters,
|
private val params: WorkerParameters,
|
||||||
private val syncedFolderProvider: SyncedFolderProvider,
|
private val syncedFolderProvider: SyncedFolderProvider,
|
||||||
private val powerManagementService: PowerManagementService,
|
private val powerManagementService: PowerManagementService,
|
||||||
private val backgroundJobManager: BackgroundJobManager
|
private val backgroundJobManager: BackgroundJobManager
|
||||||
) : Worker(appContext, params) {
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
override fun doWork(): Result {
|
companion object {
|
||||||
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
|
private const val TAG = "🔍" + "ContentObserverWork"
|
||||||
|
private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER
|
||||||
if (params.triggeredContentUris.isNotEmpty()) {
|
private const val NOTIFICATION_ID = 774
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
backgroundJobManager.scheduleContentObserverJob()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAndStartFileSyncJob() {
|
private suspend fun checkAndTriggerAutoUpload() = withContext(Dispatchers.IO) {
|
||||||
if (!powerManagementService.isPowerSavingEnabled && syncedFolderProvider.countEnabledSyncedFolders() > 0) {
|
if (powerManagementService.isPowerSavingEnabled) {
|
||||||
val changedFiles = mutableListOf<String>()
|
Log_OC.w(TAG, "⚡ Power saving mode active — skipping file sync.")
|
||||||
for (uri in params.triggeredContentUris) {
|
return@withContext
|
||||||
changedFiles.add(uri.toString())
|
}
|
||||||
}
|
|
||||||
FilesSyncHelper.startFilesSyncForAllFolders(
|
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,
|
syncedFolderProvider,
|
||||||
backgroundJobManager,
|
backgroundJobManager,
|
||||||
false,
|
false,
|
||||||
changedFiles.toTypedArray()
|
contentUris
|
||||||
)
|
)
|
||||||
} else {
|
Log_OC.d(TAG, "✅ auto upload triggered successfully for ${contentUris.size} file(s).")
|
||||||
Log_OC.w(TAG, "cant startFilesSyncForAllFolders")
|
} 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,12 @@ package com.nextcloud.client.jobs.download
|
||||||
|
|
||||||
import com.nextcloud.client.account.User
|
import com.nextcloud.client.account.User
|
||||||
import com.nextcloud.client.jobs.BackgroundJobManager
|
import com.nextcloud.client.jobs.BackgroundJobManager
|
||||||
|
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
|
||||||
import com.owncloud.android.MainApp
|
import com.owncloud.android.MainApp
|
||||||
import com.owncloud.android.datamodel.FileDataStorageManager
|
import com.owncloud.android.datamodel.FileDataStorageManager
|
||||||
import com.owncloud.android.datamodel.OCFile
|
import com.owncloud.android.datamodel.OCFile
|
||||||
import com.owncloud.android.datamodel.UploadsStorageManager
|
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.DownloadFileOperation
|
||||||
import com.owncloud.android.operations.DownloadType
|
import com.owncloud.android.operations.DownloadType
|
||||||
import com.owncloud.android.utils.MimeTypeUtil
|
import com.owncloud.android.utils.MimeTypeUtil
|
||||||
|
|
@ -29,6 +31,7 @@ class FileDownloadHelper {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private var instance: FileDownloadHelper? = null
|
private var instance: FileDownloadHelper? = null
|
||||||
|
private const val TAG = "FileDownloadHelper"
|
||||||
|
|
||||||
fun instance(): FileDownloadHelper = instance ?: synchronized(this) {
|
fun instance(): FileDownloadHelper = instance ?: synchronized(this) {
|
||||||
instance ?: FileDownloadHelper().also { instance = it }
|
instance ?: FileDownloadHelper().also { instance = it }
|
||||||
|
|
@ -44,17 +47,11 @@ class FileDownloadHelper {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val fileStorageManager = FileDataStorageManager(user, MainApp.getAppContext().contentResolver)
|
return if (file.isFolder) {
|
||||||
val topParentId = fileStorageManager.getTopParentId(file)
|
FolderDownloadWorker.isDownloading(file.fileId)
|
||||||
|
} else {
|
||||||
val isJobScheduled = backgroundJobManager.isStartFileDownloadJobScheduled(user, file.fileId)
|
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
|
||||||
return isJobScheduled ||
|
}
|
||||||
if (file.isFolder) {
|
|
||||||
FileDownloadWorker.isDownloadingFolder(file.fileId) ||
|
|
||||||
backgroundJobManager.isStartFileDownloadJobScheduled(user, topParentId)
|
|
||||||
} else {
|
|
||||||
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelPendingOrCurrentDownloads(user: User?, files: List<OCFile>?) {
|
fun cancelPendingOrCurrentDownloads(user: User?, files: List<OCFile>?) {
|
||||||
|
|
@ -141,4 +138,14 @@ class FileDownloadHelper {
|
||||||
conflictUploadId
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import com.nextcloud.client.account.UserAccountManager
|
||||||
import com.nextcloud.model.WorkerState
|
import com.nextcloud.model.WorkerState
|
||||||
import com.nextcloud.model.WorkerStateLiveData
|
import com.nextcloud.model.WorkerStateLiveData
|
||||||
import com.nextcloud.utils.ForegroundServiceHelper
|
import com.nextcloud.utils.ForegroundServiceHelper
|
||||||
import com.nextcloud.utils.extensions.getParentIdsOfSubfiles
|
|
||||||
import com.nextcloud.utils.extensions.getPercent
|
import com.nextcloud.utils.extensions.getPercent
|
||||||
import com.owncloud.android.R
|
import com.owncloud.android.R
|
||||||
import com.owncloud.android.datamodel.FileDataStorageManager
|
import com.owncloud.android.datamodel.FileDataStorageManager
|
||||||
|
|
@ -45,7 +44,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils
|
||||||
import java.util.AbstractList
|
import java.util.AbstractList
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
import java.util.Vector
|
import java.util.Vector
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("LongParameterList", "TooManyFunctions")
|
@Suppress("LongParameterList", "TooManyFunctions")
|
||||||
|
|
@ -63,7 +61,6 @@ class FileDownloadWorker(
|
||||||
private val TAG = FileDownloadWorker::class.java.simpleName
|
private val TAG = FileDownloadWorker::class.java.simpleName
|
||||||
|
|
||||||
private val pendingDownloads = IndexedForest<DownloadFileOperation>()
|
private val pendingDownloads = IndexedForest<DownloadFileOperation>()
|
||||||
private val pendingFolderDownloads: MutableSet<Long> = ConcurrentHashMap.newKeySet<Long>()
|
|
||||||
|
|
||||||
fun cancelOperation(accountName: String, fileId: Long) {
|
fun cancelOperation(accountName: String, fileId: Long) {
|
||||||
pendingDownloads.all.forEach {
|
pendingDownloads.all.forEach {
|
||||||
|
|
@ -75,8 +72,6 @@ class FileDownloadWorker(
|
||||||
it.value?.payload?.isMatching(accountName, fileId) == true
|
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 FILE_REMOTE_PATH = "FILE_REMOTE_PATH"
|
||||||
const val ACCOUNT_NAME = "ACCOUNT_NAME"
|
const val ACCOUNT_NAME = "ACCOUNT_NAME"
|
||||||
const val BEHAVIOUR = "BEHAVIOUR"
|
const val BEHAVIOUR = "BEHAVIOUR"
|
||||||
|
|
@ -170,10 +165,6 @@ class FileDownloadWorker(
|
||||||
|
|
||||||
private fun getRequestDownloads(ocFile: OCFile): AbstractList<String> {
|
private fun getRequestDownloads(ocFile: OCFile): AbstractList<String> {
|
||||||
val files = getFiles(ocFile)
|
val files = getFiles(ocFile)
|
||||||
val filesPaths = files.map { it.remotePath }
|
|
||||||
val parentIdsOfSubFiles = fileDataStorageManager?.getParentIdsOfSubfiles(filesPaths) ?: listOf()
|
|
||||||
pendingFolderDownloads.addAll(parentIdsOfSubFiles)
|
|
||||||
|
|
||||||
val downloadType = getDownloadType()
|
val downloadType = getDownloadType()
|
||||||
|
|
||||||
conflictUploadId = inputData.keyValueMap[CONFLICT_UPLOAD_ID] as Long?
|
conflictUploadId = inputData.keyValueMap[CONFLICT_UPLOAD_ID] as Long?
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,14 @@
|
||||||
*/
|
*/
|
||||||
package com.nextcloud.client.jobs.upload
|
package com.nextcloud.client.jobs.upload
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import com.nextcloud.client.account.User
|
import com.nextcloud.client.account.User
|
||||||
import com.nextcloud.client.account.UserAccountManager
|
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.BatteryStatus
|
||||||
import com.nextcloud.client.device.PowerManagementService
|
import com.nextcloud.client.device.PowerManagementService
|
||||||
import com.nextcloud.client.jobs.BackgroundJobManager
|
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.client.network.ConnectivityService
|
||||||
import com.nextcloud.utils.extensions.getUploadIds
|
import com.nextcloud.utils.extensions.getUploadIds
|
||||||
import com.owncloud.android.MainApp
|
import com.owncloud.android.MainApp
|
||||||
|
import com.owncloud.android.R
|
||||||
import com.owncloud.android.datamodel.FileDataStorageManager
|
import com.owncloud.android.datamodel.FileDataStorageManager
|
||||||
import com.owncloud.android.datamodel.OCFile
|
import com.owncloud.android.datamodel.OCFile
|
||||||
import com.owncloud.android.datamodel.UploadsStorageManager
|
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.lib.resources.files.model.RemoteFile
|
||||||
import com.owncloud.android.operations.RemoveFileOperation
|
import com.owncloud.android.operations.RemoveFileOperation
|
||||||
import com.owncloud.android.operations.UploadFileOperation
|
import com.owncloud.android.operations.UploadFileOperation
|
||||||
|
import com.owncloud.android.utils.DisplayUtils
|
||||||
import com.owncloud.android.utils.FileUtil
|
import com.owncloud.android.utils.FileUtil
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.CompletableFuture
|
|
||||||
import java.util.concurrent.ExecutionException
|
|
||||||
import java.util.concurrent.Semaphore
|
import java.util.concurrent.Semaphore
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -85,18 +88,42 @@ class FileUploadHelper {
|
||||||
fun buildRemoteName(accountName: String, remotePath: String): String = accountName + remotePath
|
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(
|
fun retryFailedUploads(
|
||||||
uploadsStorageManager: UploadsStorageManager,
|
uploadsStorageManager: UploadsStorageManager,
|
||||||
connectivityService: ConnectivityService,
|
connectivityService: ConnectivityService,
|
||||||
accountManager: UserAccountManager,
|
accountManager: UserAccountManager,
|
||||||
powerManagementService: PowerManagementService
|
powerManagementService: PowerManagementService
|
||||||
) {
|
): Boolean {
|
||||||
if (retryFailedUploadsSemaphore.tryAcquire()) {
|
if (!retryFailedUploadsSemaphore.tryAcquire()) {
|
||||||
try {
|
Log_OC.d(TAG, "skipping retryFailedUploads, already running")
|
||||||
val failedUploads = uploadsStorageManager.failedUploads
|
return true
|
||||||
if (failedUploads == null || failedUploads.isEmpty()) {
|
}
|
||||||
Log_OC.d(TAG, "Failed uploads are empty or null")
|
|
||||||
return
|
var isUploadStarted = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED) {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
isUploadStarted = true
|
||||||
}
|
}
|
||||||
|
|
||||||
retryUploads(
|
retryUploads(
|
||||||
|
|
@ -104,14 +131,14 @@ class FileUploadHelper {
|
||||||
connectivityService,
|
connectivityService,
|
||||||
accountManager,
|
accountManager,
|
||||||
powerManagementService,
|
powerManagementService,
|
||||||
failedUploads
|
uploads = it
|
||||||
)
|
)
|
||||||
} finally {
|
|
||||||
retryFailedUploadsSemaphore.release()
|
|
||||||
}
|
}
|
||||||
} else {
|
} finally {
|
||||||
Log_OC.d(TAG, "Skip retryFailedUploads since it is already running")
|
retryFailedUploadsSemaphore.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return isUploadStarted
|
||||||
}
|
}
|
||||||
|
|
||||||
fun retryCancelledUploads(
|
fun retryCancelledUploads(
|
||||||
|
|
@ -120,18 +147,18 @@ class FileUploadHelper {
|
||||||
accountManager: UserAccountManager,
|
accountManager: UserAccountManager,
|
||||||
powerManagementService: PowerManagementService
|
powerManagementService: PowerManagementService
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val cancelledUploads = uploadsStorageManager.cancelledUploadsForCurrentAccount
|
var result = false
|
||||||
if (cancelledUploads == null || cancelledUploads.isEmpty()) {
|
getUploadsByStatus(accountManager.user.accountName, UploadStatus.UPLOAD_CANCELLED) {
|
||||||
return false
|
result = retryUploads(
|
||||||
|
uploadsStorageManager,
|
||||||
|
connectivityService,
|
||||||
|
accountManager,
|
||||||
|
powerManagementService,
|
||||||
|
it
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return retryUploads(
|
return result
|
||||||
uploadsStorageManager,
|
|
||||||
connectivityService,
|
|
||||||
accountManager,
|
|
||||||
powerManagementService,
|
|
||||||
cancelledUploads
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ComplexCondition")
|
@Suppress("ComplexCondition")
|
||||||
|
|
@ -140,35 +167,32 @@ class FileUploadHelper {
|
||||||
connectivityService: ConnectivityService,
|
connectivityService: ConnectivityService,
|
||||||
accountManager: UserAccountManager,
|
accountManager: UserAccountManager,
|
||||||
powerManagementService: PowerManagementService,
|
powerManagementService: PowerManagementService,
|
||||||
failedUploads: Array<OCUpload>
|
uploads: Array<OCUpload>
|
||||||
): Boolean {
|
): Boolean {
|
||||||
var showNotExistMessage = false
|
var showNotExistMessage = false
|
||||||
val isOnline = checkConnectivity(connectivityService)
|
val isOnline = checkConnectivity(connectivityService)
|
||||||
val connectivity = connectivityService.connectivity
|
val connectivity = connectivityService.connectivity
|
||||||
val batteryStatus = powerManagementService.battery
|
val batteryStatus = powerManagementService.battery
|
||||||
val accountNames = accountManager.accounts.filter { account ->
|
|
||||||
accountManager.getUser(account.name).isPresent
|
|
||||||
}.map { account ->
|
|
||||||
account.name
|
|
||||||
}.toHashSet()
|
|
||||||
|
|
||||||
for (failedUpload in failedUploads) {
|
val uploadsToRetry = mutableListOf<Long>()
|
||||||
if (!accountNames.contains(failedUpload.accountName)) {
|
|
||||||
uploadsStorageManager.removeUpload(failedUpload)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val uploadResult =
|
for (upload in uploads) {
|
||||||
checkUploadConditions(failedUpload, connectivity, batteryStatus, powerManagementService, isOnline)
|
val uploadResult = checkUploadConditions(
|
||||||
|
upload,
|
||||||
|
connectivity,
|
||||||
|
batteryStatus,
|
||||||
|
powerManagementService,
|
||||||
|
isOnline
|
||||||
|
)
|
||||||
|
|
||||||
if (uploadResult != UploadResult.UPLOADED) {
|
if (uploadResult != UploadResult.UPLOADED) {
|
||||||
if (failedUpload.lastResult != uploadResult) {
|
if (upload.lastResult != uploadResult) {
|
||||||
// Setting Upload status else cancelled uploads will behave wrong, when retrying
|
// Setting Upload status else cancelled uploads will behave wrong, when retrying
|
||||||
// Needs to happen first since lastResult wil be overwritten by setter
|
// Needs to happen first since lastResult wil be overwritten by setter
|
||||||
failedUpload.uploadStatus = UploadStatus.UPLOAD_FAILED
|
upload.uploadStatus = UploadStatus.UPLOAD_FAILED
|
||||||
|
|
||||||
failedUpload.lastResult = uploadResult
|
upload.lastResult = uploadResult
|
||||||
uploadsStorageManager.updateUpload(failedUpload)
|
uploadsStorageManager.updateUpload(upload)
|
||||||
}
|
}
|
||||||
if (uploadResult == UploadResult.FILE_NOT_FOUND) {
|
if (uploadResult == UploadResult.FILE_NOT_FOUND) {
|
||||||
showNotExistMessage = true
|
showNotExistMessage = true
|
||||||
|
|
@ -176,15 +200,18 @@ class FileUploadHelper {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
failedUpload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
|
// Only uploads that passed checks get marked in progress and are collected for scheduling
|
||||||
uploadsStorageManager.updateUpload(failedUpload)
|
upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
|
||||||
|
uploadsStorageManager.updateUpload(upload)
|
||||||
|
uploadsToRetry.add(upload.uploadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
accountNames.forEach { accountName ->
|
if (uploadsToRetry.isNotEmpty()) {
|
||||||
val user = accountManager.getUser(accountName)
|
backgroundJobManager.startFilesUploadJob(
|
||||||
if (user.isPresent) {
|
accountManager.user,
|
||||||
backgroundJobManager.startFilesUploadJob(user.get(), failedUploads.getUploadIds(), false)
|
uploadsToRetry.toLongArray(),
|
||||||
}
|
false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return showNotExistMessage
|
return showNotExistMessage
|
||||||
|
|
@ -205,7 +232,7 @@ class FileUploadHelper {
|
||||||
showSameFileAlreadyExistsNotification: Boolean = true
|
showSameFileAlreadyExistsNotification: Boolean = true
|
||||||
) {
|
) {
|
||||||
val uploads = localPaths.mapIndexed { index, localPath ->
|
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
|
this.nameCollisionPolicy = nameCollisionPolicy
|
||||||
isUseWifiOnly = requiresWifi
|
isUseWifiOnly = requiresWifi
|
||||||
isWhileChargingOnly = requiresCharging
|
isWhileChargingOnly = requiresCharging
|
||||||
|
|
@ -214,47 +241,54 @@ class FileUploadHelper {
|
||||||
isCreateRemoteFolder = createRemoteFolder
|
isCreateRemoteFolder = createRemoteFolder
|
||||||
localAction = localBehavior
|
localAction = localBehavior
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity())
|
||||||
|
result.uploadId = id
|
||||||
|
result
|
||||||
}
|
}
|
||||||
uploadsStorageManager.storeUploads(uploads)
|
|
||||||
backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification)
|
backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeFileUpload(remotePath: String, accountName: String) {
|
fun removeFileUpload(remotePath: String, accountName: String) {
|
||||||
try {
|
uploadsStorageManager.uploadDao.deleteByAccountAndRemotePath(accountName, remotePath)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelFileUpload(remotePath: String, accountName: String) {
|
fun updateUploadStatus(remotePath: String, accountName: String, status: UploadStatus) {
|
||||||
ioScope.launch {
|
ioScope.launch {
|
||||||
val upload = uploadsStorageManager.getUploadByRemotePath(remotePath)
|
uploadsStorageManager.uploadDao.updateStatus(remotePath, accountName, status.value)
|
||||||
if (upload != null) {
|
|
||||||
cancelFileUploads(listOf(upload), accountName)
|
|
||||||
} else {
|
|
||||||
Log_OC.e(TAG, "Error cancelling current upload because upload does not exist!")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelFileUploads(uploads: List<OCUpload>, accountName: String) {
|
/**
|
||||||
for (upload in uploads) {
|
* Retrieves uploads filtered by their status, optionally for a specific account.
|
||||||
upload.uploadStatus = UploadStatus.UPLOAD_CANCELLED
|
*
|
||||||
uploadsStorageManager.updateUpload(upload)
|
* 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
|
||||||
try {
|
* given [status] from **all user accounts** are returned.
|
||||||
val user = accountManager.getUser(accountName).get()
|
*
|
||||||
cancelAndRestartUploadJob(user, uploads.getUploadIds())
|
* Once the uploads are fetched, the [onCompleted] callback is invoked with the resulting array.
|
||||||
} catch (e: NoSuchElementException) {
|
*
|
||||||
Log_OC.e(TAG, "Error restarting upload job because user does not exist!: " + e.message)
|
* @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")
|
@Suppress("ReturnCount")
|
||||||
fun isUploading(user: User?, file: OCFile?): Boolean {
|
fun isUploading(remotePath: String?, accountName: String?): Boolean {
|
||||||
if (user == null || file == null || !backgroundJobManager.isStartFileUploadJobScheduled(user)) {
|
accountName ?: return false
|
||||||
|
if (!backgroundJobManager.isStartFileUploadJobScheduled(accountName)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val uploadCompletableFuture = CompletableFuture.supplyAsync {
|
remotePath ?: return false
|
||||||
uploadsStorageManager.getUploadByRemotePath(file.remotePath)
|
val upload = uploadsStorageManager.uploadDao.getByRemotePath(remotePath)
|
||||||
}
|
return upload?.status == UploadStatus.UPLOAD_IN_PROGRESS.value ||
|
||||||
return try {
|
FileUploadWorker.isUploading(remotePath, accountName)
|
||||||
val upload = uploadCompletableFuture.get()
|
|
||||||
if (upload != null) {
|
|
||||||
upload.uploadStatus == UploadStatus.UPLOAD_IN_PROGRESS
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} catch (e: ExecutionException) {
|
|
||||||
false
|
|
||||||
} catch (e: InterruptedException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkConnectivity(connectivityService: ConnectivityService): Boolean {
|
private fun checkConnectivity(connectivityService: ConnectivityService): Boolean {
|
||||||
|
|
@ -364,7 +388,7 @@ class FileUploadHelper {
|
||||||
|
|
||||||
val uploads = existingFiles.map { file ->
|
val uploads = existingFiles.map { file ->
|
||||||
file?.let {
|
file?.let {
|
||||||
OCUpload(file, user).apply {
|
val result = OCUpload(file, user).apply {
|
||||||
fileSize = file.fileLength
|
fileSize = file.fileLength
|
||||||
this.nameCollisionPolicy = nameCollisionPolicy
|
this.nameCollisionPolicy = nameCollisionPolicy
|
||||||
isCreateRemoteFolder = true
|
isCreateRemoteFolder = true
|
||||||
|
|
@ -373,9 +397,12 @@ class FileUploadHelper {
|
||||||
isWhileChargingOnly = false
|
isWhileChargingOnly = false
|
||||||
uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
|
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()
|
val uploadIds: LongArray = uploads.filterNotNull().map { it.uploadId }.toLongArray()
|
||||||
backgroundJobManager.startFilesUploadJob(user, uploadIds, true)
|
backgroundJobManager.startFilesUploadJob(user, uploadIds, true)
|
||||||
}
|
}
|
||||||
|
|
@ -459,6 +486,14 @@ class FileUploadHelper {
|
||||||
return false
|
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() {
|
class UploadNotificationActionReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val accountName = intent.getStringExtra(FileUploadWorker.EXTRA_ACCOUNT_NAME)
|
val accountName = intent.getStringExtra(FileUploadWorker.EXTRA_ACCOUNT_NAME)
|
||||||
|
|
@ -474,7 +509,9 @@ class FileUploadHelper {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
instance().cancelFileUpload(remotePath, accountName)
|
FileUploadWorker.cancelCurrentUpload(remotePath, accountName, onCompleted = {
|
||||||
|
instance().updateUploadStatus(remotePath, accountName, UploadStatus.UPLOAD_CANCELLED)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@
|
||||||
*/
|
*/
|
||||||
package com.nextcloud.client.jobs.upload
|
package com.nextcloud.client.jobs.upload
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import androidx.work.Worker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.nextcloud.client.account.User
|
import com.nextcloud.client.account.User
|
||||||
import com.nextcloud.client.account.UserAccountManager
|
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.client.preferences.AppPreferences
|
||||||
import com.nextcloud.model.WorkerState
|
import com.nextcloud.model.WorkerState
|
||||||
import com.nextcloud.model.WorkerStateLiveData
|
import com.nextcloud.model.WorkerStateLiveData
|
||||||
|
import com.nextcloud.utils.ForegroundServiceHelper
|
||||||
import com.nextcloud.utils.extensions.getPercent
|
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.FileDataStorageManager
|
||||||
|
import com.owncloud.android.datamodel.ForegroundServiceType
|
||||||
import com.owncloud.android.datamodel.ThumbnailsCacheManager
|
import com.owncloud.android.datamodel.ThumbnailsCacheManager
|
||||||
import com.owncloud.android.datamodel.UploadsStorageManager
|
import com.owncloud.android.datamodel.UploadsStorageManager
|
||||||
import com.owncloud.android.db.OCUpload
|
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.operations.RemoteOperationResult.ResultCode
|
||||||
import com.owncloud.android.lib.common.utils.Log_OC
|
import com.owncloud.android.lib.common.utils.Log_OC
|
||||||
import com.owncloud.android.operations.UploadFileOperation
|
import com.owncloud.android.operations.UploadFileOperation
|
||||||
|
import com.owncloud.android.ui.notifications.NotificationUtils
|
||||||
import com.owncloud.android.utils.ErrorMessageAdapter
|
import com.owncloud.android.utils.ErrorMessageAdapter
|
||||||
import com.owncloud.android.utils.theme.ViewThemeUtils
|
import com.owncloud.android.utils.theme.ViewThemeUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
|
@ -51,7 +61,7 @@ class FileUploadWorker(
|
||||||
val preferences: AppPreferences,
|
val preferences: AppPreferences,
|
||||||
val context: Context,
|
val context: Context,
|
||||||
params: WorkerParameters
|
params: WorkerParameters
|
||||||
) : Worker(context, params),
|
) : CoroutineWorker(context, params),
|
||||||
OnDatatransferProgressListener {
|
OnDatatransferProgressListener {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -91,19 +101,44 @@ class FileUploadWorker(
|
||||||
fun getUploadStartMessage(): String = FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE
|
fun getUploadStartMessage(): String = FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE
|
||||||
|
|
||||||
fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_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 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 intents = FileUploaderIntents(context)
|
||||||
private val fileUploaderDelegate = FileUploaderDelegate()
|
private val fileUploaderDelegate = FileUploaderDelegate()
|
||||||
|
|
||||||
@Suppress("TooGenericExceptionCaught")
|
@Suppress("TooGenericExceptionCaught")
|
||||||
override fun doWork(): Result = try {
|
override suspend fun doWork(): Result = try {
|
||||||
Log_OC.d(TAG, "FileUploadWorker started")
|
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()
|
val result = uploadFiles()
|
||||||
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
|
backgroundJobManager.logEndOfWorker(workerName, result)
|
||||||
notificationManager.dismissNotification()
|
notificationManager.dismissNotification()
|
||||||
if (result == Result.success()) {
|
if (result == Result.success()) {
|
||||||
setIdleWorkerState()
|
setIdleWorkerState()
|
||||||
|
|
@ -111,17 +146,37 @@ class FileUploadWorker(
|
||||||
result
|
result
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log_OC.e(TAG, "Error caught at FileUploadWorker $t")
|
Log_OC.e(TAG, "Error caught at FileUploadWorker $t")
|
||||||
|
cleanup()
|
||||||
Result.failure()
|
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")
|
Log_OC.e(TAG, "FileUploadWorker stopped")
|
||||||
|
|
||||||
setIdleWorkerState()
|
setIdleWorkerState()
|
||||||
currentUploadFileOperation?.cancel(null)
|
currentUploadFileOperation?.cancel(null)
|
||||||
notificationManager.dismissNotification()
|
notificationManager.dismissNotification()
|
||||||
|
|
||||||
super.onStopped()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setWorkerState(user: User?) {
|
private fun setWorkerState(user: User?) {
|
||||||
|
|
@ -133,36 +188,36 @@ class FileUploadWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ReturnCount", "LongMethod")
|
@Suppress("ReturnCount", "LongMethod")
|
||||||
private fun uploadFiles(): Result {
|
private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) {
|
||||||
val accountName = inputData.getString(ACCOUNT)
|
val accountName = inputData.getString(ACCOUNT)
|
||||||
if (accountName == null) {
|
if (accountName == null) {
|
||||||
Log_OC.e(TAG, "accountName is null")
|
Log_OC.e(TAG, "accountName is null")
|
||||||
return Result.failure()
|
return@withContext Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val uploadIds = inputData.getLongArray(UPLOAD_IDS)
|
val uploadIds = inputData.getLongArray(UPLOAD_IDS)
|
||||||
if (uploadIds == null) {
|
if (uploadIds == null) {
|
||||||
Log_OC.e(TAG, "uploadIds is null")
|
Log_OC.e(TAG, "uploadIds is null")
|
||||||
return Result.failure()
|
return@withContext Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1)
|
val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1)
|
||||||
if (currentBatchIndex == -1) {
|
if (currentBatchIndex == -1) {
|
||||||
Log_OC.e(TAG, "currentBatchIndex is -1, cancelling")
|
Log_OC.e(TAG, "currentBatchIndex is -1, cancelling")
|
||||||
return Result.failure()
|
return@withContext Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1)
|
val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1)
|
||||||
if (totalUploadSize == -1) {
|
if (totalUploadSize == -1) {
|
||||||
Log_OC.e(TAG, "totalUploadSize is -1, cancelling")
|
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
|
// 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)
|
val optionalUser = userAccountManager.getUser(accountName)
|
||||||
if (!optionalUser.isPresent) {
|
if (!optionalUser.isPresent) {
|
||||||
Log_OC.e(TAG, "User not found for account: $accountName")
|
Log_OC.e(TAG, "User not found for account: $accountName")
|
||||||
return Result.failure()
|
return@withContext Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val user = optionalUser.get()
|
val user = optionalUser.get()
|
||||||
|
|
@ -172,21 +227,19 @@ class FileUploadWorker(
|
||||||
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
|
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
|
||||||
|
|
||||||
for ((index, upload) in uploads.withIndex()) {
|
for ((index, upload) in uploads.withIndex()) {
|
||||||
|
ensureActive()
|
||||||
|
|
||||||
if (preferences.isGlobalUploadPaused) {
|
if (preferences.isGlobalUploadPaused) {
|
||||||
Log_OC.d(TAG, "Upload is paused, skip uploading files!")
|
Log_OC.d(TAG, "Upload is paused, skip uploading files!")
|
||||||
notificationManager.notifyPaused(
|
notificationManager.notifyPaused(
|
||||||
intents.notificationStartIntent(null)
|
intents.notificationStartIntent(null)
|
||||||
)
|
)
|
||||||
return Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canExitEarly()) {
|
if (canExitEarly()) {
|
||||||
notificationManager.showConnectionErrorNotification()
|
notificationManager.showConnectionErrorNotification()
|
||||||
return Result.failure()
|
return@withContext Result.failure()
|
||||||
}
|
|
||||||
|
|
||||||
if (isStopped) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setWorkerState(user)
|
setWorkerState(user)
|
||||||
|
|
@ -203,12 +256,16 @@ class FileUploadWorker(
|
||||||
totalUploadSize = totalUploadSize
|
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
|
currentUploadFileOperation = null
|
||||||
sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result)
|
sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendUploadFinishEvent(
|
private fun sendUploadFinishEvent(
|
||||||
|
|
@ -346,6 +403,10 @@ class FileUploadWorker(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uploadResult.code == ResultCode.USER_CANCELLED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
notificationManager.run {
|
notificationManager.run {
|
||||||
val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(
|
val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(
|
||||||
uploadResult,
|
uploadResult,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
package com.nextcloud.client.logger.ui
|
package com.nextcloud.client.logger.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
|
@ -17,6 +18,7 @@ import com.nextcloud.client.logger.LogsRepository
|
||||||
import com.owncloud.android.R
|
import com.owncloud.android.R
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SuppressLint("StaticFieldLeak")
|
||||||
class LogsViewModel @Inject constructor(
|
class LogsViewModel @Inject constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
clock: Clock,
|
clock: Clock,
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ class WhatsNewActivity :
|
||||||
object : OnBackPressedCallback(true) {
|
object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
onFinish()
|
onFinish()
|
||||||
|
isEnabled = false
|
||||||
onBackPressedDispatcher.onBackPressed()
|
onBackPressedDispatcher.onBackPressed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,12 @@ public interface AppPreferences {
|
||||||
|
|
||||||
boolean isShowHiddenFilesEnabled();
|
boolean isShowHiddenFilesEnabled();
|
||||||
void setShowHiddenFilesEnabled(boolean enabled);
|
void setShowHiddenFilesEnabled(boolean enabled);
|
||||||
|
|
||||||
|
boolean isSortFoldersBeforeFiles();
|
||||||
|
void setSortFoldersBeforeFiles(boolean enabled);
|
||||||
|
|
||||||
|
boolean isSortFavoritesFirst();
|
||||||
|
void setSortFavoritesFirst(boolean enabled);
|
||||||
|
|
||||||
boolean isShowEcosystemApps();
|
boolean isShowEcosystemApps();
|
||||||
void setShowEcosystemApps(boolean enabled);
|
void setShowEcosystemApps(boolean enabled);
|
||||||
|
|
@ -344,10 +350,6 @@ public interface AppPreferences {
|
||||||
|
|
||||||
long getPhotoSearchTimestamp();
|
long getPhotoSearchTimestamp();
|
||||||
|
|
||||||
boolean isPowerCheckDisabled();
|
|
||||||
|
|
||||||
void setPowerCheckDisabled(boolean value);
|
|
||||||
|
|
||||||
void increasePinWrongAttempts();
|
void increasePinWrongAttempts();
|
||||||
|
|
||||||
void resetPinWrongAttempts();
|
void resetPinWrongAttempts();
|
||||||
|
|
|
||||||
|
|
@ -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_UPLOADING = "instant_uploading";
|
||||||
private static final String PREF__INSTANT_VIDEO_UPLOADING = "instant_video_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__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__SHOW_ECOSYSTEM_APPS = "show_ecosystem_apps";
|
||||||
private static final String PREF__LEGACY_CLEAN = "legacyClean";
|
private static final String PREF__LEGACY_CLEAN = "legacyClean";
|
||||||
private static final String PREF__KEYS_MIGRATION = "keysMigration";
|
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__SELECTED_ACCOUNT_NAME = "select_oc_account";
|
||||||
private static final String PREF__MIGRATED_USER_ID = "migrated_user_id";
|
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__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__PIN_BRUTE_FORCE_COUNT = "pin_brute_force_count";
|
||||||
private static final String PREF__UID_PID = "uid_pid";
|
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();
|
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
|
@Override
|
||||||
public boolean isShowEcosystemApps() {
|
public boolean isShowEcosystemApps() {
|
||||||
return preferences.getBoolean(PREF__SHOW_ECOSYSTEM_APPS, true);
|
return preferences.getBoolean(PREF__SHOW_ECOSYSTEM_APPS, true);
|
||||||
|
|
@ -689,16 +710,6 @@ public final class AppPreferencesImpl implements AppPreferences {
|
||||||
return preferenceName + "_" + folderIdString;
|
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() {
|
public void increasePinWrongAttempts() {
|
||||||
int count = preferences.getInt(PREF__PIN_BRUTE_FORCE_COUNT, 0);
|
int count = preferences.getInt(PREF__PIN_BRUTE_FORCE_COUNT, 0);
|
||||||
preferences.edit().putInt(PREF__PIN_BRUTE_FORCE_COUNT, count + 1).apply();
|
preferences.edit().putInt(PREF__PIN_BRUTE_FORCE_COUNT, count + 1).apply();
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ enum class SearchResultEntryType {
|
||||||
Unknown;
|
Unknown;
|
||||||
|
|
||||||
fun iconId(): Int = when (this) {
|
fun iconId(): Int = when (this) {
|
||||||
|
CalendarEvent -> R.drawable.file_calendar
|
||||||
Folder -> R.drawable.folder
|
Folder -> R.drawable.folder
|
||||||
Note -> R.drawable.ic_edit
|
Note -> R.drawable.ic_edit
|
||||||
Contact -> R.drawable.file_vcard
|
Contact -> R.drawable.file_vcard
|
||||||
CalendarEvent -> R.drawable.file_calendar
|
|
||||||
Deck -> R.drawable.ic_deck
|
Deck -> R.drawable.ic_deck
|
||||||
else -> R.drawable.ic_find_in_page
|
Unknown -> R.drawable.ic_find_in_page
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ class ChooseAccountDialogFragment :
|
||||||
return builder.create()
|
return builder.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
accountManager = (activity as BaseActivity).userAccountManager
|
accountManager = (activity as BaseActivity).userAccountManager
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@ class SetStatusMessageBottomSheet(val user: User, val currentStatus: Status?) :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
fun setPredefinedStatus(predefinedStatus: ArrayList<PredefinedStatus>) {
|
fun setPredefinedStatus(predefinedStatus: ArrayList<PredefinedStatus>) {
|
||||||
adapter.list = predefinedStatus
|
adapter.list = predefinedStatus
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import com.nextcloud.client.assistant.AssistantScreen
|
import com.nextcloud.client.assistant.AssistantScreen
|
||||||
import com.nextcloud.client.assistant.AssistantViewModel
|
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.common.NextcloudClient
|
||||||
import com.nextcloud.utils.extensions.getSerializableArgument
|
import com.nextcloud.utils.extensions.getSerializableArgument
|
||||||
import com.owncloud.android.R
|
import com.owncloud.android.R
|
||||||
|
|
@ -79,10 +81,14 @@ class ComposeActivity : DrawerActivity() {
|
||||||
isChecked = true
|
isChecked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val dao = NextcloudDatabase.instance().assistantDao()
|
||||||
|
|
||||||
nextcloudClient?.let { client ->
|
nextcloudClient?.let { client ->
|
||||||
AssistantScreen(
|
AssistantScreen(
|
||||||
viewModel = AssistantViewModel(
|
viewModel = AssistantViewModel(
|
||||||
repository = AssistantRepository(client, capabilities)
|
accountName = userAccountManager.user.accountName,
|
||||||
|
remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities),
|
||||||
|
localRepository = AssistantLocalRepositoryImpl(dao)
|
||||||
),
|
),
|
||||||
activity = this,
|
activity = this,
|
||||||
capability = capabilities
|
capability = capabilities
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ enum class FileAction(
|
||||||
|
|
||||||
// Uploads and downloads
|
// Uploads and downloads
|
||||||
DOWNLOAD_FILE(R.id.action_download_file, R.string.filedetails_download, R.drawable.ic_cloud_download),
|
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),
|
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_cloud_sync_off),
|
CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync, R.drawable.ic_sync_off),
|
||||||
|
|
||||||
// File sharing
|
// File sharing
|
||||||
EXPORT_FILE(R.id.action_export_file, R.string.filedetails_export, R.drawable.ic_export),
|
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_SHARE_FILE,
|
||||||
SEND_FILE,
|
SEND_FILE,
|
||||||
OPEN_FILE_WITH,
|
OPEN_FILE_WITH,
|
||||||
SYNC_FILE,
|
DOWNLOAD_FOLDER,
|
||||||
CANCEL_SYNC,
|
CANCEL_SYNC,
|
||||||
SELECT_ALL,
|
SELECT_ALL,
|
||||||
SELECT_NONE,
|
SELECT_NONE,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,15 @@
|
||||||
package com.nextcloud.utils
|
package com.nextcloud.utils
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
import androidx.core.graphics.scale
|
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")
|
@Suppress("MagicNumber")
|
||||||
fun Bitmap.allocationKilobyte(): Int = allocationByteCount.div(1024)
|
fun Bitmap.allocationKilobyte(): Int = allocationByteCount.div(1024)
|
||||||
|
|
@ -38,3 +46,115 @@ fun Bitmap.scaleUntil(targetKB: Int): Bitmap {
|
||||||
val scaledBitmap = scale(width, height)
|
val scaledBitmap = scale(width, height)
|
||||||
return scaledBitmap.scaleUntil(targetKB)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
67
app/src/main/java/com/nextcloud/utils/FileHelper.kt
Normal file
67
app/src/main/java/com/nextcloud/utils/FileHelper.kt
Normal 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
Loading…
Add table
Add a link
Reference in a new issue