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)
|
||||
|
||||
- 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"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/com.nextcloud.client/)
|
||||
[<img src="https://github.com/user-attachments/assets/713d71c5-3dec-4ec4-a3f2-8d28d025a9c6"
|
||||
alt="Get it with Obtainium"
|
||||
height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.nextcloud.client%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fnextcloud%2Fandroid%22%2C%22author%22%3A%22nextcloud%22%2C%22name%22%3A%22Nextcloud%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Enextcloud.*%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22Nextcloud%20ist%20eine%20Cloudanwendung%2C%20die%20selbst%20gehostet%20werden%20kann.%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Atrue%7D%22%2C%22overrideSource%22%3Anull%7D)
|
||||
|
||||
Signing certificate fingerprint to [verify](https://developer.android.com/studio/command-line/apksigner#usage-verify) the APK:
|
||||
- APK with "gplay" name, found [here](https://github.com/nextcloud/android/releases) or distributed via Google Play Store
|
||||
|
|
|
|||
120
Readme-AR.md
Normal file
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" />
|
||||
</issue>
|
||||
|
||||
<issue id="PluralsCandidate">
|
||||
<ignore path="**/values-**/strings.xml" />
|
||||
</issue>
|
||||
|
||||
<issue id="ExtraTranslation">
|
||||
<ignore path="**/strings.xml"/>
|
||||
<ignore path="**/values-b+en+001/strings.xml"/>
|
||||
|
|
|
|||
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 {
|
||||
EspressoIdlingResource.increment()
|
||||
val dialog = sut.buildPowerCheckDialog()
|
||||
dialog.show()
|
||||
sut.showPowerCheckDialog()
|
||||
|
||||
EspressoIdlingResource.decrement()
|
||||
|
||||
val screenShotName = createName(testClassName + "_" + "showPowerCheckDialog", "")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
package com.nextcloud.client.assistant
|
||||
|
||||
import com.nextcloud.client.assistant.repository.AssistantRepository
|
||||
import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositoryImpl
|
||||
import com.owncloud.android.AbstractOnServerIT
|
||||
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
|
||||
import com.owncloud.android.lib.resources.status.NextcloudVersion
|
||||
|
|
@ -18,11 +18,11 @@ import org.junit.Test
|
|||
@Suppress("MagicNumber")
|
||||
class AssistantRepositoryTests : AbstractOnServerIT() {
|
||||
|
||||
private var sut: AssistantRepository? = null
|
||||
private var sut: AssistantRemoteRepositoryImpl? = null
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
sut = AssistantRepository(nextcloudClient, capability)
|
||||
sut = AssistantRemoteRepositoryImpl(nextcloudClient, capability)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ class TransferManagerConnectionTest {
|
|||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// WHEN
|
||||
// is runnign flag accessed
|
||||
// is running flag accessed
|
||||
val isRunning = connection.isRunning
|
||||
|
||||
// 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.owncloud.android.AbstractOnServerIT
|
||||
import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile
|
||||
import com.owncloud.android.lib.resources.status.CapabilityBooleanType
|
||||
import com.owncloud.android.lib.resources.status.NextcloudVersion
|
||||
import com.owncloud.android.lib.resources.status.OCCapability
|
||||
import org.junit.Before
|
||||
|
|
@ -27,6 +28,7 @@ class AutoRenameTests : AbstractOnServerIT() {
|
|||
testOnlyOnServer(NextcloudVersion.nextcloud_30)
|
||||
|
||||
capability = capability.apply {
|
||||
isWCFEnabled = CapabilityBooleanType.TRUE
|
||||
forbiddenFilenameExtensionJson = listOf(
|
||||
"""[" ",".",".part",".part"]""",
|
||||
"""[".",".part",".part"," "]""",
|
||||
|
|
@ -238,4 +240,14 @@ class AutoRenameTests : AbstractOnServerIT() {
|
|||
val expectedFilename = "Foo.Bar.Baz"
|
||||
assert(result == expectedFilename) { "Expected $expectedFilename but got $result" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipAutoRenameWhenWCFDisabled() {
|
||||
capability = capability.apply {
|
||||
isWCFEnabled = CapabilityBooleanType.FALSE
|
||||
}
|
||||
val filename = " readme.txt "
|
||||
val result = AutoRename.rename(filename, capability, isFolderPath = true)
|
||||
assert(result == filename) { "Expected $filename but got $result" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.owncloud.android.AbstractOnServerIT
|
||||
import com.owncloud.android.R
|
||||
import com.owncloud.android.lib.resources.status.CapabilityBooleanType
|
||||
import com.owncloud.android.lib.resources.status.NextcloudVersion
|
||||
import com.owncloud.android.lib.resources.status.OCCapability
|
||||
import org.junit.Assert.assertEquals
|
||||
|
|
@ -27,6 +28,7 @@ class FileNameValidatorTests : AbstractOnServerIT() {
|
|||
@Before
|
||||
fun setup() {
|
||||
capability = capability.apply {
|
||||
isWCFEnabled = CapabilityBooleanType.TRUE
|
||||
forbiddenFilenamesJson = """[".htaccess",".htaccess"]"""
|
||||
forbiddenFilenameBaseNamesJson = """
|
||||
["con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4",
|
||||
|
|
@ -228,4 +230,14 @@ class FileNameValidatorTests : AbstractOnServerIT() {
|
|||
val result = FileNameValidator.checkFolderAndFilePaths(folderPath, listOf(), capability, targetContext)
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipValidationWhenWCFDisabled() {
|
||||
capability = capability.apply {
|
||||
isWCFEnabled = CapabilityBooleanType.FALSE
|
||||
}
|
||||
val filename = "abc.txt"
|
||||
val result = FileNameValidator.checkFileName(filename, capability, targetContext)
|
||||
assertNull(result)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,11 +400,6 @@ public abstract class AbstractIT {
|
|||
public boolean isPowerSavingEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPowerSavingExclusionAvailable() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);
|
||||
|
|
|
|||
|
|
@ -216,11 +216,6 @@ public abstract class AbstractOnServerIT extends AbstractIT {
|
|||
public boolean isPowerSavingEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPowerSavingExclusionAvailable() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);
|
||||
|
|
|
|||
|
|
@ -82,12 +82,6 @@ public class UploadIT extends AbstractOnServerIT {
|
|||
public boolean isPowerSavingEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPowerSavingExclusionAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public BatteryStatus getBattery() {
|
||||
|
|
@ -237,11 +231,6 @@ public class UploadIT extends AbstractOnServerIT {
|
|||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPowerSavingExclusionAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public BatteryStatus getBattery() {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.nextcloud.client.account.CurrentAccountProvider;
|
|||
import com.nextcloud.client.account.User;
|
||||
import com.nextcloud.client.account.UserAccountManager;
|
||||
import com.nextcloud.client.account.UserAccountManagerImpl;
|
||||
import com.nextcloud.client.database.entity.UploadEntityKt;
|
||||
import com.nextcloud.test.RandomStringGenerator;
|
||||
import com.owncloud.android.AbstractIT;
|
||||
import com.owncloud.android.MainApp;
|
||||
|
|
@ -108,7 +109,7 @@ public class UploadStorageManagerTest extends AbstractIT {
|
|||
OCUpload upload = createUpload(account);
|
||||
|
||||
uploads.add(upload);
|
||||
uploadsStorageManager.storeUpload(upload);
|
||||
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(upload));
|
||||
}
|
||||
|
||||
OCUpload[] storedUploads = uploadsStorageManager.getAllStoredUploads();
|
||||
|
|
@ -151,17 +152,14 @@ public class UploadStorageManagerTest extends AbstractIT {
|
|||
account.name);
|
||||
|
||||
corruptUpload.setLocalPath(null);
|
||||
|
||||
uploadsStorageManager.storeUpload(corruptUpload);
|
||||
|
||||
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(corruptUpload));
|
||||
uploadsStorageManager.getAllStoredUploads();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getById() {
|
||||
OCUpload upload = createUpload(account);
|
||||
long id = uploadsStorageManager.storeUpload(upload);
|
||||
|
||||
long id = uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(upload));
|
||||
OCUpload newUpload = uploadsStorageManager.getUploadById(id);
|
||||
|
||||
assertNotNull(newUpload);
|
||||
|
|
@ -178,7 +176,7 @@ public class UploadStorageManagerTest extends AbstractIT {
|
|||
|
||||
private void insertUploads(Account account, int rowsToInsert) {
|
||||
for (int i = 0; i < rowsToInsert; i++) {
|
||||
uploadsStorageManager.storeUpload(createUpload(account));
|
||||
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(createUpload(account)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,9 +46,6 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
|||
override val isPowerSavingEnabled: Boolean
|
||||
get() = false
|
||||
|
||||
override val isPowerSavingExclusionAvailable: Boolean
|
||||
get() = false
|
||||
|
||||
override val battery: BatteryStatus
|
||||
get() = BatteryStatus()
|
||||
}
|
||||
|
|
@ -327,7 +324,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
|||
user,
|
||||
null,
|
||||
ocUpload2,
|
||||
NameCollisionPolicy.CANCEL,
|
||||
NameCollisionPolicy.SKIP,
|
||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||
targetContext,
|
||||
false,
|
||||
|
|
@ -376,7 +373,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
|||
user,
|
||||
arrayOf(ocFile2),
|
||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||
NameCollisionPolicy.CANCEL
|
||||
NameCollisionPolicy.SKIP
|
||||
)
|
||||
|
||||
shortSleep()
|
||||
|
|
@ -403,7 +400,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
|||
user,
|
||||
null,
|
||||
ocUpload,
|
||||
NameCollisionPolicy.CANCEL,
|
||||
NameCollisionPolicy.SKIP,
|
||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||
targetContext,
|
||||
false,
|
||||
|
|
@ -429,7 +426,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
|||
user,
|
||||
null,
|
||||
ocUpload2,
|
||||
NameCollisionPolicy.CANCEL,
|
||||
NameCollisionPolicy.SKIP,
|
||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||
targetContext,
|
||||
false,
|
||||
|
|
@ -480,7 +477,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
|
|||
user,
|
||||
arrayOf(ocFile2),
|
||||
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
|
||||
NameCollisionPolicy.CANCEL
|
||||
NameCollisionPolicy.SKIP
|
||||
)
|
||||
|
||||
shortSleep()
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ public class FileDisplayActivityTest extends AbstractIT {
|
|||
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
|
||||
Activity activity =
|
||||
ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(RESUMED).iterator().next();
|
||||
if (activity instanceof WhatsNewActivity) {
|
||||
activity.onBackPressed();
|
||||
if (activity instanceof WhatsNewActivity whatsNewActivity) {
|
||||
whatsNewActivity.getOnBackPressedDispatcher().onBackPressed();
|
||||
}
|
||||
});
|
||||
scenario.recreate();
|
||||
|
|
|
|||
|
|
@ -822,7 +822,7 @@ class FileDetailSharingFragmentIT : AbstractIT() {
|
|||
val processFragment =
|
||||
activity.supportFragmentManager.findFragmentByTag(FileDetailsSharingProcessFragment.TAG) as
|
||||
FileDetailsSharingProcessFragment
|
||||
processFragment.onBackPressed()
|
||||
processFragment.activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class UnifiedSearchFragmentIT : AbstractIT() {
|
|||
scenario.onActivity { activity ->
|
||||
onIdleSync {
|
||||
EspressoIdlingResource.increment()
|
||||
val sut = UnifiedSearchFragment.newInstance(null, null)
|
||||
val sut = UnifiedSearchFragment.newInstance(null, null, "/")
|
||||
activity.addFragment(sut)
|
||||
|
||||
sut.onSearchResultChanged(
|
||||
|
|
@ -83,7 +83,7 @@ class UnifiedSearchFragmentIT : AbstractIT() {
|
|||
onIdleSync {
|
||||
EspressoIdlingResource.increment()
|
||||
|
||||
val sut = UnifiedSearchFragment.newInstance(null, null)
|
||||
val sut = UnifiedSearchFragment.newInstance(null, null, "/")
|
||||
val testViewModel = UnifiedSearchViewModel(activity.application)
|
||||
testViewModel.setConnectivityService(activity.connectivityServiceMock)
|
||||
val localRepository = UnifiedSearchFakeRepository()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ class CapabilityUtilsIT : AbstractIT() {
|
|||
assertTrue(test(OwnCloudVersion.nextcloud_20))
|
||||
}
|
||||
|
||||
private fun test(version: OwnCloudVersion): Boolean =
|
||||
CapabilityUtils.checkOutdatedWarning(targetContext.resources, version, false)
|
||||
@Test
|
||||
fun checkOutdatedWarningWithSubscription() {
|
||||
assertFalse(test(NextcloudVersion.nextcloud_31))
|
||||
assertFalse(test(NextcloudVersion.nextcloud_30))
|
||||
|
||||
assertFalse(test(OwnCloudVersion.nextcloud_20, true))
|
||||
}
|
||||
|
||||
private fun test(version: OwnCloudVersion, hasValidSubscription: Boolean = false): Boolean =
|
||||
CapabilityUtils.checkOutdatedWarning(targetContext.resources, version, false, hasValidSubscription)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import java.io.FileInputStream;
|
|||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.Key;
|
||||
import java.security.KeyFactory;
|
||||
|
|
@ -96,7 +97,11 @@ public final class PushUtils {
|
|||
if (!new File(privateKeyPath).exists() && !new File(publicKeyPath).exists()) {
|
||||
try {
|
||||
if (!keyPathFile.exists()) {
|
||||
keyPathFile.mkdir();
|
||||
try {
|
||||
Files.createDirectory(keyPathFile.toPath());
|
||||
} catch (IOException e) {
|
||||
Log_OC.e(TAG, "Could not create directory: " + keyPathFile.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||
keyGen.initialize(2048);
|
||||
|
|
@ -304,8 +309,12 @@ public final class PushUtils {
|
|||
try {
|
||||
if (!new File(path).exists()) {
|
||||
File newFile = new File(path);
|
||||
newFile.getParentFile().mkdirs();
|
||||
newFile.createNewFile();
|
||||
try {
|
||||
Files.createDirectories(newFile.getParentFile().toPath());
|
||||
} catch (IOException e) {
|
||||
Log_OC.e(TAG, "Could not create directory: " + newFile.getParentFile(), e);
|
||||
}
|
||||
Files.createFile(newFile.toPath());
|
||||
}
|
||||
keyFileOutputStream = new FileOutputStream(path);
|
||||
keyFileOutputStream.write(encoded);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Nextcloud - Android Client
|
||||
~
|
||||
~ SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
|
||||
|
|
@ -53,8 +52,10 @@
|
|||
must request the FOREGROUND_SERVICE permission
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Runtime permissions introduced in Android 13 (API level 33) -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <!-- Needed for Android 14 (API level 34) -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"
|
||||
tools:ignore="PhotoAndVideoPolicy,SelectedPhotoAccess" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"
|
||||
tools:ignore="PhotoAndVideoPolicy,SelectedPhotoAccess" /> <!-- Needed for Android 14 (API level 34) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!--
|
||||
|
|
@ -112,6 +113,7 @@
|
|||
android:name=".MainApp"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:installLocation="internalOnly"
|
||||
|
|
@ -121,13 +123,13 @@
|
|||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:theme="@style/Theme.ownCloud.Toolbar"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="UnusedAttribute"
|
||||
tools:replace="android:allowBackup">
|
||||
|
||||
<meta-data android:name="android.content.APP_RESTRICTIONS"
|
||||
<meta-data
|
||||
android:name="android.content.APP_RESTRICTIONS"
|
||||
android:resource="@xml/app_config" />
|
||||
|
||||
<activity
|
||||
|
|
@ -159,7 +161,9 @@
|
|||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:host="*" />
|
||||
<data
|
||||
android:host="*"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:pathPattern="/f/..*" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
|
@ -169,7 +173,9 @@
|
|||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:host="*" />
|
||||
<data
|
||||
android:host="*"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:pathPattern="/..*/f/..*" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
|
@ -179,7 +185,9 @@
|
|||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:host="*" />
|
||||
<data
|
||||
android:host="*"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:pathPattern="/..*/..*/f/..*" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
|
@ -189,7 +197,9 @@
|
|||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:host="*" />
|
||||
<data
|
||||
android:host="*"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:pathPattern="/..*/..*/..*/f/..*" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
|
@ -199,7 +209,9 @@
|
|||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*" />
|
||||
<data
|
||||
android:host="*"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:pathPattern="/f/..*" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
|
@ -209,7 +221,9 @@
|
|||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*" />
|
||||
<data
|
||||
android:host="*"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:pathPattern="/..*/f/..*" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
|
@ -219,7 +233,9 @@
|
|||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*" />
|
||||
<data
|
||||
android:host="*"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:pathPattern="/..*/..*/f/..*" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
|
@ -229,7 +245,9 @@
|
|||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*" />
|
||||
<data
|
||||
android:host="*"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:pathPattern="/..*/..*/..*/f/..*" />
|
||||
<!-- path pattern to handle deep link -->
|
||||
<data android:pathPattern="/app/..*" />
|
||||
|
|
@ -352,6 +370,7 @@
|
|||
</activity>
|
||||
<activity
|
||||
android:name=".ui.activity.SettingsActivity"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:exported="false"
|
||||
android:theme="@style/PreferenceTheme" />
|
||||
<activity
|
||||
|
|
@ -366,11 +385,11 @@
|
|||
|
||||
<service
|
||||
android:name="com.nextcloud.client.media.BackgroundPlayerService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
|
|
@ -584,6 +603,10 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name="com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.activity.CopyToClipboardActivity"
|
||||
android:exported="false"
|
||||
|
|
|
|||
|
|
@ -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 com.nextcloud.client.assistant.model.ScreenOverlayState
|
||||
import com.nextcloud.client.assistant.model.ScreenState
|
||||
import com.nextcloud.client.assistant.repository.AssistantRepositoryType
|
||||
import com.nextcloud.client.assistant.repository.local.AssistantLocalRepository
|
||||
import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository
|
||||
import com.owncloud.android.R
|
||||
import com.owncloud.android.lib.common.utils.Log_OC
|
||||
import com.owncloud.android.lib.resources.assistant.v2.model.Task
|
||||
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AssistantViewModel(private val repository: AssistantRepositoryType) : ViewModel() {
|
||||
class AssistantViewModel(
|
||||
private val accountName: String,
|
||||
private val remoteRepository: AssistantRemoteRepository,
|
||||
private val localRepository: AssistantLocalRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AssistantViewModel"
|
||||
private const val TASK_LIST_POLLING_INTERVAL_MS = 15_000L
|
||||
}
|
||||
|
||||
private val _screenState = MutableStateFlow<ScreenState?>(null)
|
||||
val screenState: StateFlow<ScreenState?> = _screenState
|
||||
|
|
@ -44,14 +57,54 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
|||
private val _filteredTaskList = MutableStateFlow<List<Task>?>(null)
|
||||
val filteredTaskList: StateFlow<List<Task>?> = _filteredTaskList
|
||||
|
||||
private var taskPollingJob: Job? = null
|
||||
|
||||
init {
|
||||
fetchTaskTypes()
|
||||
}
|
||||
|
||||
// region task polling
|
||||
fun startTaskListPolling() {
|
||||
stopTaskListPolling()
|
||||
|
||||
taskPollingJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
while (isActive) {
|
||||
Log_OC.d(TAG, "Polling task list...")
|
||||
fetchTaskListSuspending()
|
||||
delay(TASK_LIST_POLLING_INTERVAL_MS)
|
||||
}
|
||||
} finally {
|
||||
Log_OC.d(TAG, "Polling coroutine cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopTaskListPolling() {
|
||||
taskPollingJob?.cancel()
|
||||
taskPollingJob = null
|
||||
}
|
||||
// endregion
|
||||
|
||||
private suspend fun fetchTaskListSuspending() {
|
||||
val cachedTasks = localRepository.getCachedTasks(accountName)
|
||||
if (cachedTasks.isNotEmpty()) {
|
||||
_filteredTaskList.value = cachedTasks.sortedByDescending { it.id }
|
||||
}
|
||||
|
||||
val taskType = _selectedTaskType.value?.id ?: return
|
||||
val result = remoteRepository.getTaskList(taskType)
|
||||
if (result != null) {
|
||||
taskList = result
|
||||
_filteredTaskList.value = taskList?.sortedByDescending { it.id }
|
||||
localRepository.cacheTasks(result, accountName)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun createTask(input: String, taskType: TaskTypeData) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result = repository.createTask(input, taskType)
|
||||
val result = remoteRepository.createTask(input, taskType)
|
||||
|
||||
val messageId = if (result.isSuccess) {
|
||||
R.string.assistant_screen_task_create_success_message
|
||||
|
|
@ -76,15 +129,11 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
|||
|
||||
private fun fetchTaskTypes() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val taskTypesResult = repository.getTaskTypes()
|
||||
|
||||
if (taskTypesResult == null) {
|
||||
updateSnackbarMessage(R.string.assistant_screen_task_types_error_state_message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (taskTypesResult.isEmpty()) {
|
||||
updateSnackbarMessage(R.string.assistant_screen_task_list_empty_message)
|
||||
val taskTypesResult = remoteRepository.getTaskTypes()
|
||||
if (taskTypesResult == null || taskTypesResult.isEmpty()) {
|
||||
_screenState.update {
|
||||
ScreenState.emptyTaskTypes()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
|
@ -98,12 +147,17 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
|||
|
||||
fun fetchTaskList() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_screenState.update {
|
||||
ScreenState.Refreshing
|
||||
// Try cached data first
|
||||
val cachedTasks = localRepository.getCachedTasks(accountName)
|
||||
if (cachedTasks.isNotEmpty()) {
|
||||
_filteredTaskList.update {
|
||||
cachedTasks.sortedByDescending { it.id }
|
||||
}
|
||||
updateTaskListScreenState()
|
||||
}
|
||||
|
||||
val taskType = _selectedTaskType.value?.id ?: return@launch
|
||||
val result = repository.getTaskList(taskType)
|
||||
val result = remoteRepository.getTaskList(taskType)
|
||||
if (result != null) {
|
||||
taskList = result
|
||||
_filteredTaskList.update {
|
||||
|
|
@ -111,19 +165,21 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
|||
task.id
|
||||
}
|
||||
}
|
||||
|
||||
localRepository.cacheTasks(result, accountName)
|
||||
updateSnackbarMessage(null)
|
||||
} else {
|
||||
updateSnackbarMessage(R.string.assistant_screen_task_list_error_state_message)
|
||||
}
|
||||
|
||||
updateScreenState()
|
||||
updateTaskListScreenState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateScreenState() {
|
||||
private fun updateTaskListScreenState() {
|
||||
_screenState.update {
|
||||
if (_filteredTaskList.value?.isEmpty() == true) {
|
||||
ScreenState.EmptyContent
|
||||
ScreenState.emptyTaskList()
|
||||
} else {
|
||||
ScreenState.Content
|
||||
}
|
||||
|
|
@ -132,7 +188,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
|||
|
||||
fun deleteTask(id: Long) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result = repository.deleteTask(id)
|
||||
val result = remoteRepository.deleteTask(id)
|
||||
|
||||
val messageId = if (result.isSuccess) {
|
||||
R.string.assistant_screen_task_delete_success_message
|
||||
|
|
@ -144,6 +200,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
|||
|
||||
if (result.isSuccess) {
|
||||
removeTaskFromList(id)
|
||||
localRepository.deleteTask(id, accountName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +211,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
|
|||
}
|
||||
}
|
||||
|
||||
fun updateScreenState(value: ScreenOverlayState?) {
|
||||
fun updateTaskListScreenState(value: ScreenOverlayState?) {
|
||||
_screenOverlayState.update {
|
||||
value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,24 @@
|
|||
|
||||
package com.nextcloud.client.assistant.model
|
||||
|
||||
enum class ScreenState {
|
||||
Refreshing,
|
||||
EmptyContent,
|
||||
Content
|
||||
import com.owncloud.android.R
|
||||
|
||||
sealed class ScreenState {
|
||||
data object Loading : ScreenState()
|
||||
|
||||
data object Content : ScreenState()
|
||||
|
||||
data class EmptyContent(val iconId: Int?, val descriptionId: Int) : ScreenState()
|
||||
|
||||
companion object {
|
||||
fun emptyTaskTypes(): ScreenState = EmptyContent(
|
||||
descriptionId = R.string.assistant_screen_task_list_empty_warning,
|
||||
iconId = null
|
||||
)
|
||||
|
||||
fun emptyTaskList(): ScreenState = EmptyContent(
|
||||
descriptionId = R.string.assistant_screen_create_a_new_task_from_bottom_right_text,
|
||||
iconId = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -17,6 +19,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -28,9 +32,11 @@ import androidx.compose.material3.ModalBottomSheet
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -54,29 +60,54 @@ fun TaskDetailBottomSheet(task: Task, showTaskActions: () -> Unit, dismiss: () -
|
|||
onDismissRequest = { dismiss() },
|
||||
sheetState = sheetState
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
stickyHeader {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
stickyHeader {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
IconButton(onClick = showTaskActions) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = "More button",
|
||||
tint = colorResource(R.color.text_color)
|
||||
)
|
||||
IconButton(onClick = showTaskActions) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = "More button",
|
||||
tint = colorResource(R.color.text_color)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
InputOutputCard(task)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
InputOutputCard(task)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_assistant),
|
||||
contentDescription = "assistant icon",
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.assistant_generation_warning),
|
||||
color = colorResource(R.color.text_color),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@
|
|||
package com.nextcloud.client.assistant.taskTypes
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -26,13 +25,13 @@ import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
|
|||
fun TaskTypesRow(selectedTaskType: TaskTypeData?, data: List<TaskTypeData>, selectTaskType: (TaskTypeData) -> Unit) {
|
||||
val selectedTabIndex = data.indexOfFirst { it.id == selectedTaskType?.id }.takeIf { it >= 0 } ?: 0
|
||||
|
||||
ScrollableTabRow(
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
edgePadding = 0.dp,
|
||||
containerColor = colorResource(R.color.actionbar_color),
|
||||
indicator = {
|
||||
TabRowDefaults.SecondaryIndicator(
|
||||
Modifier.tabIndicatorOffset(it[selectedTabIndex]),
|
||||
Modifier.tabIndicatorOffset(selectedTabIndex),
|
||||
color = colorResource(R.color.primary)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,15 @@ import androidx.room.TypeConverters
|
|||
import com.nextcloud.client.core.Clock
|
||||
import com.nextcloud.client.core.ClockImpl
|
||||
import com.nextcloud.client.database.dao.ArbitraryDataDao
|
||||
import com.nextcloud.client.database.dao.AssistantDao
|
||||
import com.nextcloud.client.database.dao.FileDao
|
||||
import com.nextcloud.client.database.dao.FileSystemDao
|
||||
import com.nextcloud.client.database.dao.OfflineOperationDao
|
||||
import com.nextcloud.client.database.dao.RecommendedFileDao
|
||||
import com.nextcloud.client.database.dao.SyncedFolderDao
|
||||
import com.nextcloud.client.database.dao.UploadDao
|
||||
import com.nextcloud.client.database.entity.ArbitraryDataEntity
|
||||
import com.nextcloud.client.database.entity.AssistantEntity
|
||||
import com.nextcloud.client.database.entity.CapabilityEntity
|
||||
import com.nextcloud.client.database.entity.ExternalLinkEntity
|
||||
import com.nextcloud.client.database.entity.FileEntity
|
||||
|
|
@ -37,6 +41,7 @@ import com.nextcloud.client.database.migrations.Migration67to68
|
|||
import com.nextcloud.client.database.migrations.RoomMigration
|
||||
import com.nextcloud.client.database.migrations.addLegacyMigrations
|
||||
import com.nextcloud.client.database.typeConverter.OfflineOperationTypeConverter
|
||||
import com.owncloud.android.MainApp
|
||||
import com.owncloud.android.db.ProviderMeta
|
||||
|
||||
@Database(
|
||||
|
|
@ -51,7 +56,8 @@ import com.owncloud.android.db.ProviderMeta
|
|||
UploadEntity::class,
|
||||
VirtualEntity::class,
|
||||
OfflineOperationEntity::class,
|
||||
RecommendedFileEntity::class
|
||||
RecommendedFileEntity::class,
|
||||
AssistantEntity::class
|
||||
],
|
||||
version = ProviderMeta.DB_VERSION,
|
||||
autoMigrations = [
|
||||
|
|
@ -81,7 +87,10 @@ import com.owncloud.android.db.ProviderMeta
|
|||
AutoMigration(from = 89, to = 90),
|
||||
AutoMigration(from = 90, to = 91),
|
||||
AutoMigration(from = 91, to = 92),
|
||||
AutoMigration(from = 92, to = 93, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class)
|
||||
AutoMigration(from = 92, to = 93, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class),
|
||||
AutoMigration(from = 93, to = 94, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class),
|
||||
AutoMigration(from = 94, to = 95, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class),
|
||||
AutoMigration(from = 95, to = 96)
|
||||
],
|
||||
exportSchema = true
|
||||
)
|
||||
|
|
@ -94,6 +103,9 @@ abstract class NextcloudDatabase : RoomDatabase() {
|
|||
abstract fun offlineOperationDao(): OfflineOperationDao
|
||||
abstract fun uploadDao(): UploadDao
|
||||
abstract fun recommendedFileDao(): RecommendedFileDao
|
||||
abstract fun fileSystemDao(): FileSystemDao
|
||||
abstract fun syncedFolderDao(): SyncedFolderDao
|
||||
abstract fun assistantDao(): AssistantDao
|
||||
|
||||
companion object {
|
||||
const val FIRST_ROOM_DB_VERSION = 65
|
||||
|
|
@ -119,5 +131,9 @@ abstract class NextcloudDatabase : RoomDatabase() {
|
|||
}
|
||||
return instance!!
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@JvmStatic
|
||||
fun instance(): NextcloudDatabase = getInstance(MainApp.getAppContext())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
@Dao
|
||||
interface FileDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT parent
|
||||
FROM filelist
|
||||
WHERE path IN (:subfilePaths)
|
||||
"""
|
||||
)
|
||||
fun getParentIdsOfSubfiles(subfilePaths: List<String>): List<Long>
|
||||
|
||||
@Update
|
||||
fun update(entity: FileEntity)
|
||||
|
||||
|
|
@ -108,4 +99,16 @@ interface FileDao {
|
|||
dirType: String = MimeType.DIRECTORY,
|
||||
webdavType: String = MimeType.WEBDAV_FOLDER
|
||||
): List<FileEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT *
|
||||
FROM filelist
|
||||
WHERE file_owner = :fileOwner
|
||||
AND parent = :parentId
|
||||
AND ${ProviderTableMeta.FILE_NAME} LIKE '%' || :query || '%'
|
||||
ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}
|
||||
"""
|
||||
)
|
||||
fun searchFilesInFolder(parentId: Long, fileOwner: String, query: String): List<FileEntity>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.nextcloud.client.database.entity.UploadEntity
|
||||
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
|
||||
|
|
@ -27,4 +29,68 @@ interface UploadDao {
|
|||
ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName"
|
||||
)
|
||||
fun getUploadsByIds(ids: LongArray, accountName: String): List<UploadEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
|
||||
"WHERE ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath LIMIT 1"
|
||||
)
|
||||
fun getByRemotePath(remotePath: String): UploadEntity?
|
||||
|
||||
@Query(
|
||||
"DELETE FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
|
||||
"WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName " +
|
||||
"AND ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath"
|
||||
)
|
||||
fun deleteByAccountAndRemotePath(accountName: String, remotePath: String)
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME +
|
||||
" WHERE " + ProviderTableMeta._ID + " = :id AND " +
|
||||
ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName " +
|
||||
"LIMIT 1"
|
||||
)
|
||||
fun getUploadById(id: Long, accountName: String): UploadEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
|
||||
fun insertOrReplace(entity: UploadEntity): Long
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME +
|
||||
" WHERE " + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName AND " +
|
||||
ProviderTableMeta.UPLOADS_LOCAL_PATH + " = :localPath AND " +
|
||||
ProviderTableMeta.UPLOADS_REMOTE_PATH + " = :remotePath " +
|
||||
"LIMIT 1"
|
||||
)
|
||||
fun getUploadByAccountAndPaths(accountName: String, localPath: String, remotePath: String): UploadEntity?
|
||||
|
||||
@Query(
|
||||
"UPDATE ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
|
||||
"SET ${ProviderTableMeta.UPLOADS_STATUS} = :status " +
|
||||
"WHERE ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath " +
|
||||
"AND ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName"
|
||||
)
|
||||
suspend fun updateStatus(remotePath: String, accountName: String, status: Int): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME}
|
||||
WHERE ${ProviderTableMeta.UPLOADS_STATUS} = :status
|
||||
AND (:nameCollisionPolicy IS NULL OR ${ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY} = :nameCollisionPolicy)
|
||||
"""
|
||||
)
|
||||
suspend fun getUploadsByStatus(status: Int, nameCollisionPolicy: Int? = null): List<UploadEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME}
|
||||
WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName
|
||||
AND ${ProviderTableMeta.UPLOADS_STATUS} = :status
|
||||
AND (:nameCollisionPolicy IS NULL OR ${ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY} = :nameCollisionPolicy)
|
||||
"""
|
||||
)
|
||||
suspend fun getUploadsByAccountNameAndStatus(
|
||||
accountName: String,
|
||||
status: Int,
|
||||
nameCollisionPolicy: Int? = null
|
||||
): List<UploadEntity>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
val defaultPermissions: Int?,
|
||||
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_BUSY)
|
||||
val userStatusSupportsBusy: Int?
|
||||
val userStatusSupportsBusy: Int?,
|
||||
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES)
|
||||
val isWCFEnabled: Int?,
|
||||
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION)
|
||||
val hasValidSubscription: Int?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ package com.nextcloud.client.database.entity
|
|||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.nextcloud.client.preferences.SubFolderRule
|
||||
import com.owncloud.android.datamodel.MediaFolderType
|
||||
import com.owncloud.android.datamodel.SyncedFolder
|
||||
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
|
||||
|
||||
@Entity(tableName = ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME)
|
||||
|
|
@ -50,3 +53,40 @@ data class SyncedFolderEntity(
|
|||
@ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_LAST_SCAN_TIMESTAMP_MS)
|
||||
val lastScanTimestampMs: Long?
|
||||
)
|
||||
|
||||
fun SyncedFolderEntity.toSyncedFolder(): SyncedFolder = SyncedFolder(
|
||||
// id
|
||||
(this.id ?: SyncedFolder.UNPERSISTED_ID).toLong(),
|
||||
// localPath
|
||||
this.localPath ?: "",
|
||||
// remotePath
|
||||
this.remotePath ?: "",
|
||||
// wifiOnly
|
||||
this.wifiOnly == 1,
|
||||
// chargingOnly
|
||||
this.chargingOnly == 1,
|
||||
// existing
|
||||
this.existing == 1,
|
||||
// subfolderByDate
|
||||
this.subfolderByDate == 1,
|
||||
// account
|
||||
this.account ?: "",
|
||||
// uploadAction
|
||||
this.uploadAction ?: 0,
|
||||
// nameCollisionPolicy
|
||||
this.nameCollisionPolicy ?: 0,
|
||||
// enabled
|
||||
this.enabled == 1,
|
||||
// timestampMs
|
||||
(this.enabledTimestampMs ?: SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS).toLong(),
|
||||
// type
|
||||
MediaFolderType.getById(this.type ?: MediaFolderType.CUSTOM.id),
|
||||
// hidden
|
||||
this.hidden == 1,
|
||||
// subFolderRule
|
||||
this.subFolderRule?.let { SubFolderRule.entries[it] },
|
||||
// excludeHidden
|
||||
this.excludeHidden == 1,
|
||||
// lastScanTimestampMs
|
||||
this.lastScanTimestampMs ?: SyncedFolder.NOT_SCANNED_YET
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
* Nextcloud - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
|
||||
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
|
||||
|
|
@ -78,3 +79,33 @@ fun UploadEntity.toOCUpload(capability: OCCapability? = null): OCUpload {
|
|||
|
||||
return upload
|
||||
}
|
||||
|
||||
fun OCUpload.toUploadEntity(): UploadEntity {
|
||||
val id = if (uploadId == -1L) {
|
||||
// needed for the insert new records to the db so that insert DAO function returns new generated id
|
||||
null
|
||||
} else {
|
||||
uploadId
|
||||
}
|
||||
|
||||
return UploadEntity(
|
||||
id = id?.toInt(),
|
||||
localPath = localPath,
|
||||
remotePath = remotePath,
|
||||
accountName = accountName,
|
||||
fileSize = fileSize,
|
||||
status = uploadStatus?.value,
|
||||
localBehaviour = localAction,
|
||||
nameCollisionPolicy = nameCollisionPolicy?.serialize(),
|
||||
isCreateRemoteFolder = if (isCreateRemoteFolder) 1 else 0,
|
||||
|
||||
// uploadEndTimestamp may overflow max int capacity since it is conversion from long to int. coerceAtMost needed
|
||||
uploadEndTimestamp = uploadEndTimestamp.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(),
|
||||
lastResult = lastResult?.value,
|
||||
createdBy = createdBy,
|
||||
isWifiOnly = if (isUseWifiOnly) 1 else 0,
|
||||
isWhileChargingOnly = if (isWhileChargingOnly) 1 else 0,
|
||||
folderUnlockToken = folderUnlockToken,
|
||||
uploadTime = null
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ package com.nextcloud.client.device
|
|||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import com.nextcloud.client.preferences.AppPreferences
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
||||
|
|
@ -18,13 +17,11 @@ import dagger.Provides
|
|||
class DeviceModule {
|
||||
|
||||
@Provides
|
||||
fun powerManagementService(context: Context, preferences: AppPreferences): PowerManagementService {
|
||||
fun powerManagementService(context: Context): PowerManagementService {
|
||||
val platformPowerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return PowerManagementServiceImpl(
|
||||
context = context,
|
||||
platformPowerManager = platformPowerManager,
|
||||
deviceInfo = DeviceInfo(),
|
||||
preferences = preferences
|
||||
platformPowerManager = platformPowerManager
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,6 @@ interface PowerManagementService {
|
|||
*/
|
||||
val isPowerSavingEnabled: Boolean
|
||||
|
||||
/**
|
||||
* Checks if the device vendor requires power saving
|
||||
* exclusion workaround.
|
||||
*
|
||||
* @return true if workaround is required, false otherwise
|
||||
*/
|
||||
val isPowerSavingExclusionAvailable: Boolean
|
||||
|
||||
/**
|
||||
* Checks current battery status using platform [android.os.BatteryManager]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,46 +11,27 @@ import android.content.Intent
|
|||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.os.PowerManager
|
||||
import com.nextcloud.client.preferences.AppPreferences
|
||||
import com.nextcloud.client.preferences.AppPreferencesImpl
|
||||
import com.nextcloud.utils.extensions.registerBroadcastReceiver
|
||||
import com.owncloud.android.datamodel.ReceiverFlag
|
||||
|
||||
internal class PowerManagementServiceImpl(
|
||||
private val context: Context,
|
||||
private val platformPowerManager: PowerManager,
|
||||
private val preferences: AppPreferences,
|
||||
private val deviceInfo: DeviceInfo = DeviceInfo()
|
||||
private val platformPowerManager: PowerManager
|
||||
) : PowerManagementService {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Vendors on this list use aggressive power saving methods that might
|
||||
* break application experience.
|
||||
*/
|
||||
val OVERLY_AGGRESSIVE_POWER_SAVING_VENDORS = setOf("samsung", "huawei", "xiaomi")
|
||||
|
||||
@JvmStatic
|
||||
fun fromContext(context: Context): PowerManagementServiceImpl {
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val preferences = AppPreferencesImpl.fromContext(context)
|
||||
|
||||
return PowerManagementServiceImpl(context, powerManager, preferences, DeviceInfo())
|
||||
return PowerManagementServiceImpl(context, powerManager)
|
||||
}
|
||||
}
|
||||
|
||||
override val isPowerSavingEnabled: Boolean
|
||||
get() {
|
||||
if (preferences.isPowerCheckDisabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
return platformPowerManager.isPowerSaveMode
|
||||
}
|
||||
|
||||
override val isPowerSavingExclusionAvailable: Boolean
|
||||
get() = deviceInfo.vendor in OVERLY_AGGRESSIVE_POWER_SAVING_VENDORS
|
||||
|
||||
@Suppress("MagicNumber") // 100% is 100, we're not doing Cobol
|
||||
override val battery: BatteryStatus
|
||||
get() {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import com.nextcloud.client.integrations.IntegrationsModule;
|
|||
import com.nextcloud.client.jobs.JobsModule;
|
||||
import com.nextcloud.client.jobs.download.FileDownloadHelper;
|
||||
import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver;
|
||||
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver;
|
||||
import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver;
|
||||
import com.nextcloud.client.jobs.upload.FileUploadHelper;
|
||||
import com.nextcloud.client.media.BackgroundPlayerService;
|
||||
|
|
@ -75,6 +76,8 @@ public interface AppComponent {
|
|||
|
||||
void inject(OfflineOperationReceiver offlineOperationReceiver);
|
||||
|
||||
void inject(FolderDownloadWorkerReceiver folderDownloadWorkerReceiver);
|
||||
|
||||
@Component.Builder
|
||||
interface Builder {
|
||||
@BindsInstance
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class DocumentScanActivity :
|
|||
true
|
||||
}
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.nextcloud.client.di.Injectable
|
||||
|
|
@ -46,6 +47,7 @@ class EtmActivity :
|
|||
onPageChanged(it)
|
||||
}
|
||||
)
|
||||
handleOnBackPressed()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
|
|
@ -58,11 +60,17 @@ class EtmActivity :
|
|||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
if (!vm.onBackPressed()) {
|
||||
super.onBackPressed()
|
||||
}
|
||||
private fun handleOnBackPressed() {
|
||||
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val handledByVm = vm.onBackPressed()
|
||||
|
||||
if (!handledByVm) {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun onPageChanged(page: EtmMenuEntry?) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
package com.nextcloud.client.etm
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
|
@ -20,6 +21,7 @@ class EtmMenuAdapter(context: Context, val onItemClicked: (Int) -> Unit) :
|
|||
|
||||
private val layoutInflater = LayoutInflater.from(context)
|
||||
var pages: List<EtmMenuEntry> = listOf()
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
package com.nextcloud.client.etm.pages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
|
|
@ -63,6 +64,7 @@ class EtmFileTransferFragment : EtmBaseFragment() {
|
|||
|
||||
private var transfers = listOf<Transfer>()
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun setStatus(status: TransferManager.Status) {
|
||||
transfers = listOf(status.pending, status.running, status.completed).flatten().reversed()
|
||||
notifyDataSetChanged()
|
||||
|
|
|
|||
|
|
@ -17,14 +17,17 @@ import androidx.work.WorkerFactory
|
|||
import androidx.work.WorkerParameters
|
||||
import com.nextcloud.client.account.UserAccountManager
|
||||
import com.nextcloud.client.core.Clock
|
||||
import com.nextcloud.client.device.DeviceInfo
|
||||
import com.nextcloud.client.database.NextcloudDatabase
|
||||
import com.nextcloud.client.device.PowerManagementService
|
||||
import com.nextcloud.client.documentscan.GeneratePDFUseCase
|
||||
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
|
||||
import com.nextcloud.client.integrations.deck.DeckApi
|
||||
import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker
|
||||
import com.nextcloud.client.jobs.autoUpload.FileSystemRepository
|
||||
import com.nextcloud.client.jobs.download.FileDownloadWorker
|
||||
import com.nextcloud.client.jobs.metadata.MetadataWorker
|
||||
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
|
||||
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
|
||||
import com.nextcloud.client.jobs.upload.FileUploadWorker
|
||||
import com.nextcloud.client.logger.Logger
|
||||
import com.nextcloud.client.network.ConnectivityService
|
||||
|
|
@ -50,7 +53,6 @@ class BackgroundJobFactory @Inject constructor(
|
|||
private val clock: Clock,
|
||||
private val powerManagementService: PowerManagementService,
|
||||
private val backgroundJobManager: Provider<BackgroundJobManager>,
|
||||
private val deviceInfo: DeviceInfo,
|
||||
private val accountManager: UserAccountManager,
|
||||
private val resources: Resources,
|
||||
private val arbitraryDataProvider: ArbitraryDataProvider,
|
||||
|
|
@ -62,7 +64,8 @@ class BackgroundJobFactory @Inject constructor(
|
|||
private val viewThemeUtils: Provider<ViewThemeUtils>,
|
||||
private val localBroadcastManager: Provider<LocalBroadcastManager>,
|
||||
private val generatePdfUseCase: GeneratePDFUseCase,
|
||||
private val syncedFolderProvider: SyncedFolderProvider
|
||||
private val syncedFolderProvider: SyncedFolderProvider,
|
||||
private val database: NextcloudDatabase
|
||||
) : WorkerFactory() {
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
|
|
@ -84,7 +87,7 @@ class BackgroundJobFactory @Inject constructor(
|
|||
when (workerClass) {
|
||||
ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters)
|
||||
ContactsImportWork::class -> createContactsImportWork(context, workerParameters)
|
||||
FilesSyncWork::class -> createFilesSyncWork(context, workerParameters)
|
||||
AutoUploadWorker::class -> createFilesSyncWork(context, workerParameters)
|
||||
OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters)
|
||||
MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters)
|
||||
NotificationWork::class -> createNotificationWork(context, workerParameters)
|
||||
|
|
@ -100,6 +103,7 @@ class BackgroundJobFactory @Inject constructor(
|
|||
OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters)
|
||||
InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters)
|
||||
MetadataWorker::class -> createMetadataWorker(context, workerParameters)
|
||||
FolderDownloadWorker::class -> createFolderDownloadWorker(context, workerParameters)
|
||||
else -> null // caller falls back to default factory
|
||||
}
|
||||
}
|
||||
|
|
@ -166,16 +170,16 @@ class BackgroundJobFactory @Inject constructor(
|
|||
contentResolver
|
||||
)
|
||||
|
||||
private fun createFilesSyncWork(context: Context, params: WorkerParameters): FilesSyncWork = FilesSyncWork(
|
||||
private fun createFilesSyncWork(context: Context, params: WorkerParameters): AutoUploadWorker = AutoUploadWorker(
|
||||
context = context,
|
||||
params = params,
|
||||
contentResolver = contentResolver,
|
||||
userAccountManager = accountManager,
|
||||
uploadsStorageManager = uploadsStorageManager,
|
||||
connectivityService = connectivityService,
|
||||
powerManagementService = powerManagementService,
|
||||
syncedFolderProvider = syncedFolderProvider,
|
||||
backgroundJobManager = backgroundJobManager.get()
|
||||
backgroundJobManager = backgroundJobManager.get(),
|
||||
repository = FileSystemRepository(dao = database.fileSystemDao())
|
||||
)
|
||||
|
||||
private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork = OfflineSyncWork(
|
||||
|
|
@ -285,4 +289,12 @@ class BackgroundJobFactory @Inject constructor(
|
|||
params,
|
||||
accountManager.user
|
||||
)
|
||||
|
||||
private fun createFolderDownloadWorker(context: Context, params: WorkerParameters): FolderDownloadWorker =
|
||||
FolderDownloadWorker(
|
||||
accountManager,
|
||||
context,
|
||||
viewThemeUtils.get(),
|
||||
params
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.work.ListenableWorker
|
||||
import com.nextcloud.client.account.User
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import com.owncloud.android.datamodel.SyncedFolder
|
||||
import com.owncloud.android.operations.DownloadType
|
||||
|
||||
/**
|
||||
|
|
@ -119,15 +120,12 @@ interface BackgroundJobManager {
|
|||
|
||||
fun startImmediateFilesExportJob(files: Collection<OCFile>): LiveData<JobInfo?>
|
||||
|
||||
fun schedulePeriodicFilesSyncJob(syncedFolderID: Long)
|
||||
fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder)
|
||||
|
||||
/**
|
||||
* Immediately start File Sync job for given syncFolderID.
|
||||
*/
|
||||
fun startImmediateFilesSyncJob(
|
||||
syncedFolderID: Long,
|
||||
fun startAutoUploadImmediately(
|
||||
syncedFolder: SyncedFolder,
|
||||
overridePowerSaving: Boolean = false,
|
||||
changedFiles: Array<String?> = arrayOf<String?>()
|
||||
contentUris: Array<String?> = arrayOf()
|
||||
)
|
||||
|
||||
fun cancelTwoWaySyncJob()
|
||||
|
|
@ -142,12 +140,10 @@ interface BackgroundJobManager {
|
|||
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
|
||||
fun getFileUploads(user: User): LiveData<List<JobInfo>>
|
||||
fun cancelFilesUploadJob(user: User)
|
||||
fun isStartFileUploadJobScheduled(user: User): Boolean
|
||||
fun isStartFileUploadJobScheduled(accountName: String): Boolean
|
||||
|
||||
fun cancelFilesDownloadJob(user: User, fileId: Long)
|
||||
|
||||
fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun startFileDownloadJob(
|
||||
user: User,
|
||||
|
|
@ -175,4 +171,6 @@ interface BackgroundJobManager {
|
|||
fun scheduleInternal2WaySync(intervalMinutes: Long)
|
||||
fun cancelAllFilesDownloadJobs()
|
||||
fun startMetadataSyncJob(currentDirPath: String)
|
||||
fun downloadFolder(folder: OCFile, accountName: String)
|
||||
fun cancelFolderDownload()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ import com.nextcloud.client.account.User
|
|||
import com.nextcloud.client.core.Clock
|
||||
import com.nextcloud.client.di.Injectable
|
||||
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
|
||||
import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker
|
||||
import com.nextcloud.client.jobs.download.FileDownloadWorker
|
||||
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
|
||||
import com.nextcloud.client.jobs.metadata.MetadataWorker
|
||||
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
|
||||
import com.nextcloud.client.jobs.upload.FileUploadHelper
|
||||
|
|
@ -35,6 +37,7 @@ import com.nextcloud.client.preferences.AppPreferences
|
|||
import com.nextcloud.utils.extensions.isWorkRunning
|
||||
import com.nextcloud.utils.extensions.isWorkScheduled
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import com.owncloud.android.datamodel.SyncedFolder
|
||||
import com.owncloud.android.operations.DownloadType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -91,6 +94,7 @@ internal class BackgroundJobManagerImpl(
|
|||
const val JOB_PERIODIC_OFFLINE_OPERATIONS = "periodic_offline_operations"
|
||||
const val JOB_PERIODIC_HEALTH_STATUS = "periodic_health_status"
|
||||
const val JOB_IMMEDIATE_HEALTH_STATUS = "immediate_health_status"
|
||||
const val JOB_DOWNLOAD_FOLDER = "download_folder"
|
||||
const val JOB_METADATA_SYNC = "metadata_sync"
|
||||
const val JOB_INTERNAL_TWO_WAY_SYNC = "internal_two_way_sync"
|
||||
|
||||
|
|
@ -472,41 +476,68 @@ internal class BackgroundJobManagerImpl(
|
|||
)
|
||||
}
|
||||
|
||||
override fun schedulePeriodicFilesSyncJob(syncedFolderID: Long) {
|
||||
override fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder) {
|
||||
val syncedFolderID = syncedFolder.id
|
||||
|
||||
val arguments = Data.Builder()
|
||||
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
|
||||
.putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID)
|
||||
.build()
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresCharging(syncedFolder.isChargingOnly)
|
||||
.build()
|
||||
|
||||
val request = periodicRequestBuilder(
|
||||
jobClass = FilesSyncWork::class,
|
||||
jobClass = AutoUploadWorker::class,
|
||||
jobName = JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
|
||||
intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES
|
||||
intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
|
||||
constraints = constraints
|
||||
)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.LINEAR,
|
||||
DEFAULT_BACKOFF_CRITERIA_DELAY_SEC,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
.setInputData(arguments)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
override fun startImmediateFilesSyncJob(
|
||||
syncedFolderID: Long,
|
||||
override fun startAutoUploadImmediately(
|
||||
syncedFolder: SyncedFolder,
|
||||
overridePowerSaving: Boolean,
|
||||
changedFiles: Array<String?>
|
||||
contentUris: Array<String?>
|
||||
) {
|
||||
val syncedFolderID = syncedFolder.id
|
||||
|
||||
val arguments = Data.Builder()
|
||||
.putBoolean(FilesSyncWork.OVERRIDE_POWER_SAVING, overridePowerSaving)
|
||||
.putStringArray(FilesSyncWork.CHANGED_FILES, changedFiles)
|
||||
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
|
||||
.putBoolean(AutoUploadWorker.OVERRIDE_POWER_SAVING, overridePowerSaving)
|
||||
.putStringArray(AutoUploadWorker.CONTENT_URIS, contentUris)
|
||||
.putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID)
|
||||
.build()
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresCharging(syncedFolder.isChargingOnly)
|
||||
.build()
|
||||
|
||||
val request = oneTimeRequestBuilder(
|
||||
jobClass = FilesSyncWork::class,
|
||||
jobClass = AutoUploadWorker::class,
|
||||
jobName = JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID
|
||||
)
|
||||
.setInputData(arguments)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.LINEAR,
|
||||
DEFAULT_BACKOFF_CRITERIA_DELAY_SEC,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
|
|
@ -606,10 +637,10 @@ internal class BackgroundJobManagerImpl(
|
|||
workManager.enqueue(request)
|
||||
}
|
||||
|
||||
private fun startFileUploadJobTag(user: User): String = JOB_FILES_UPLOAD + user.accountName
|
||||
private fun startFileUploadJobTag(accountName: String): String = JOB_FILES_UPLOAD + accountName
|
||||
|
||||
override fun isStartFileUploadJobScheduled(user: User): Boolean =
|
||||
workManager.isWorkScheduled(startFileUploadJobTag(user))
|
||||
override fun isStartFileUploadJobScheduled(accountName: String): Boolean =
|
||||
workManager.isWorkScheduled(startFileUploadJobTag(accountName))
|
||||
|
||||
/**
|
||||
* This method supports initiating uploads for various scenarios, including:
|
||||
|
|
@ -627,7 +658,7 @@ internal class BackgroundJobManagerImpl(
|
|||
defaultDispatcherScope.launch {
|
||||
val batchSize = FileUploadHelper.MAX_FILE_COUNT
|
||||
val batches = uploadIds.toList().chunked(batchSize)
|
||||
val tag = startFileUploadJobTag(user)
|
||||
val tag = startFileUploadJobTag(user.accountName)
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
|
|
@ -673,9 +704,6 @@ internal class BackgroundJobManagerImpl(
|
|||
private fun startFileDownloadJobTag(user: User, fileId: Long): String =
|
||||
JOB_FOLDER_DOWNLOAD + user.accountName + fileId
|
||||
|
||||
override fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean =
|
||||
workManager.isWorkScheduled(startFileDownloadJobTag(user, fileId))
|
||||
|
||||
override fun startFileDownloadJob(
|
||||
user: User,
|
||||
file: OCFile,
|
||||
|
|
@ -795,4 +823,28 @@ internal class BackgroundJobManagerImpl(
|
|||
|
||||
workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
|
||||
override fun downloadFolder(folder: OCFile, accountName: String) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
|
||||
val data = Data.Builder()
|
||||
.putLong(FolderDownloadWorker.FOLDER_ID, folder.fileId)
|
||||
.putString(FolderDownloadWorker.ACCOUNT_NAME, accountName)
|
||||
.build()
|
||||
|
||||
val request = oneTimeRequestBuilder(FolderDownloadWorker::class, JOB_DOWNLOAD_FOLDER)
|
||||
.addTag(JOB_DOWNLOAD_FOLDER)
|
||||
.setInputData(data)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(JOB_DOWNLOAD_FOLDER, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
|
||||
}
|
||||
|
||||
override fun cancelFolderDownload() {
|
||||
workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,27 @@
|
|||
/*
|
||||
* Nextcloud - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
|
||||
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
|
||||
*/
|
||||
package com.nextcloud.client.jobs
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import androidx.work.Worker
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.nextcloud.client.device.PowerManagementService
|
||||
import com.nextcloud.utils.ForegroundServiceHelper
|
||||
import com.owncloud.android.R
|
||||
import com.owncloud.android.datamodel.ForegroundServiceType
|
||||
import com.owncloud.android.datamodel.SyncedFolderProvider
|
||||
import com.owncloud.android.lib.common.utils.Log_OC
|
||||
import com.owncloud.android.ui.notifications.NotificationUtils
|
||||
import com.owncloud.android.utils.FilesSyncHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* This work is triggered when OS detects change in media folders.
|
||||
|
|
@ -21,53 +30,113 @@ import com.owncloud.android.utils.FilesSyncHelper
|
|||
*
|
||||
* This job must not be started on API < 24.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
class ContentObserverWork(
|
||||
appContext: Context,
|
||||
private val context: Context,
|
||||
private val params: WorkerParameters,
|
||||
private val syncedFolderProvider: SyncedFolderProvider,
|
||||
private val powerManagementService: PowerManagementService,
|
||||
private val backgroundJobManager: BackgroundJobManager
|
||||
) : Worker(appContext, params) {
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
|
||||
|
||||
if (params.triggeredContentUris.isNotEmpty()) {
|
||||
Log_OC.d(TAG, "File-sync Content Observer detected files change")
|
||||
checkAndStartFileSyncJob()
|
||||
backgroundJobManager.startMediaFoldersDetectionJob()
|
||||
} else {
|
||||
Log_OC.d(TAG, "triggeredContentUris empty")
|
||||
}
|
||||
recheduleSelf()
|
||||
|
||||
val result = Result.success()
|
||||
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
|
||||
return result
|
||||
companion object {
|
||||
private const val TAG = "🔍" + "ContentObserverWork"
|
||||
private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER
|
||||
private const val NOTIFICATION_ID = 774
|
||||
}
|
||||
|
||||
private fun recheduleSelf() {
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
val workerName = BackgroundJobManagerImpl.formatClassTag(this@ContentObserverWork::class)
|
||||
backgroundJobManager.logStartOfWorker(workerName)
|
||||
Log_OC.d(TAG, "started")
|
||||
|
||||
try {
|
||||
if (params.triggeredContentUris.isNotEmpty()) {
|
||||
Log_OC.d(TAG, "📸 content observer detected file changes.")
|
||||
|
||||
val notificationTitle = context.getString(R.string.content_observer_work_notification_title)
|
||||
val notification = createNotification(notificationTitle)
|
||||
updateForegroundInfo(notification)
|
||||
checkAndTriggerAutoUpload()
|
||||
|
||||
// prevent worker fail because of another worker
|
||||
try {
|
||||
backgroundJobManager.startMediaFoldersDetectionJob()
|
||||
} catch (e: Exception) {
|
||||
Log_OC.d(TAG, "⚠️ media folder detection job failed :$e")
|
||||
}
|
||||
} else {
|
||||
Log_OC.d(TAG, "⚠️ triggeredContentUris is empty — nothing to sync.")
|
||||
}
|
||||
|
||||
rescheduleSelf()
|
||||
|
||||
val result = Result.success()
|
||||
backgroundJobManager.logEndOfWorker(workerName, result)
|
||||
Log_OC.d(TAG, "finished")
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log_OC.e(TAG, "❌ Exception in ContentObserverWork: ${e.message}", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateForegroundInfo(notification: Notification) {
|
||||
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ForegroundServiceType.DataSync
|
||||
)
|
||||
setForeground(foregroundInfo)
|
||||
}
|
||||
|
||||
private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setSmallIcon(R.drawable.ic_find_in_page)
|
||||
.setOngoing(true)
|
||||
.setSound(null)
|
||||
.setVibrate(null)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Re-schedules this observer to ensure continuous monitoring of media changes.
|
||||
*/
|
||||
private fun rescheduleSelf() {
|
||||
Log_OC.d(TAG, "🔁 Rescheduling ContentObserverWork for continued observation.")
|
||||
backgroundJobManager.scheduleContentObserverJob()
|
||||
}
|
||||
|
||||
private fun checkAndStartFileSyncJob() {
|
||||
if (!powerManagementService.isPowerSavingEnabled && syncedFolderProvider.countEnabledSyncedFolders() > 0) {
|
||||
val changedFiles = mutableListOf<String>()
|
||||
for (uri in params.triggeredContentUris) {
|
||||
changedFiles.add(uri.toString())
|
||||
}
|
||||
FilesSyncHelper.startFilesSyncForAllFolders(
|
||||
private suspend fun checkAndTriggerAutoUpload() = withContext(Dispatchers.IO) {
|
||||
if (powerManagementService.isPowerSavingEnabled) {
|
||||
Log_OC.w(TAG, "⚡ Power saving mode active — skipping file sync.")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val enabledFoldersCount = syncedFolderProvider.countEnabledSyncedFolders()
|
||||
if (enabledFoldersCount <= 0) {
|
||||
Log_OC.w(TAG, "🚫 No enabled synced folders found — skipping file sync.")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val contentUris = params.triggeredContentUris.map { uri ->
|
||||
// adds uri strings e.g. content://media/external/images/media/2281
|
||||
uri.toString()
|
||||
}.toTypedArray()
|
||||
Log_OC.d(TAG, "📄 Content uris detected")
|
||||
|
||||
try {
|
||||
FilesSyncHelper.startAutoUploadImmediatelyWithContentUris(
|
||||
syncedFolderProvider,
|
||||
backgroundJobManager,
|
||||
false,
|
||||
changedFiles.toTypedArray()
|
||||
contentUris
|
||||
)
|
||||
} else {
|
||||
Log_OC.w(TAG, "cant startFilesSyncForAllFolders")
|
||||
Log_OC.d(TAG, "✅ auto upload triggered successfully for ${contentUris.size} file(s).")
|
||||
} catch (e: Exception) {
|
||||
Log_OC.e(TAG, "❌ Failed to start auto upload for changed files: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG: String = ContentObserverWork::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.jobs.BackgroundJobManager
|
||||
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
|
||||
import com.owncloud.android.MainApp
|
||||
import com.owncloud.android.datamodel.FileDataStorageManager
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import com.owncloud.android.datamodel.UploadsStorageManager
|
||||
import com.owncloud.android.lib.common.utils.Log_OC
|
||||
import com.owncloud.android.operations.DownloadFileOperation
|
||||
import com.owncloud.android.operations.DownloadType
|
||||
import com.owncloud.android.utils.MimeTypeUtil
|
||||
|
|
@ -29,6 +31,7 @@ class FileDownloadHelper {
|
|||
|
||||
companion object {
|
||||
private var instance: FileDownloadHelper? = null
|
||||
private const val TAG = "FileDownloadHelper"
|
||||
|
||||
fun instance(): FileDownloadHelper = instance ?: synchronized(this) {
|
||||
instance ?: FileDownloadHelper().also { instance = it }
|
||||
|
|
@ -44,17 +47,11 @@ class FileDownloadHelper {
|
|||
return false
|
||||
}
|
||||
|
||||
val fileStorageManager = FileDataStorageManager(user, MainApp.getAppContext().contentResolver)
|
||||
val topParentId = fileStorageManager.getTopParentId(file)
|
||||
|
||||
val isJobScheduled = backgroundJobManager.isStartFileDownloadJobScheduled(user, file.fileId)
|
||||
return isJobScheduled ||
|
||||
if (file.isFolder) {
|
||||
FileDownloadWorker.isDownloadingFolder(file.fileId) ||
|
||||
backgroundJobManager.isStartFileDownloadJobScheduled(user, topParentId)
|
||||
} else {
|
||||
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
|
||||
}
|
||||
return if (file.isFolder) {
|
||||
FolderDownloadWorker.isDownloading(file.fileId)
|
||||
} else {
|
||||
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelPendingOrCurrentDownloads(user: User?, files: List<OCFile>?) {
|
||||
|
|
@ -141,4 +138,14 @@ class FileDownloadHelper {
|
|||
conflictUploadId
|
||||
)
|
||||
}
|
||||
|
||||
fun downloadFolder(folder: OCFile?, accountName: String) {
|
||||
if (folder == null) {
|
||||
Log_OC.e(TAG, "folder cannot be null, cant sync")
|
||||
return
|
||||
}
|
||||
backgroundJobManager.downloadFolder(folder, accountName)
|
||||
}
|
||||
|
||||
fun cancelFolderDownload() = backgroundJobManager.cancelFolderDownload()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import com.nextcloud.client.account.UserAccountManager
|
|||
import com.nextcloud.model.WorkerState
|
||||
import com.nextcloud.model.WorkerStateLiveData
|
||||
import com.nextcloud.utils.ForegroundServiceHelper
|
||||
import com.nextcloud.utils.extensions.getParentIdsOfSubfiles
|
||||
import com.nextcloud.utils.extensions.getPercent
|
||||
import com.owncloud.android.R
|
||||
import com.owncloud.android.datamodel.FileDataStorageManager
|
||||
|
|
@ -45,7 +44,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils
|
|||
import java.util.AbstractList
|
||||
import java.util.Optional
|
||||
import java.util.Vector
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
|
|
@ -63,7 +61,6 @@ class FileDownloadWorker(
|
|||
private val TAG = FileDownloadWorker::class.java.simpleName
|
||||
|
||||
private val pendingDownloads = IndexedForest<DownloadFileOperation>()
|
||||
private val pendingFolderDownloads: MutableSet<Long> = ConcurrentHashMap.newKeySet<Long>()
|
||||
|
||||
fun cancelOperation(accountName: String, fileId: Long) {
|
||||
pendingDownloads.all.forEach {
|
||||
|
|
@ -75,8 +72,6 @@ class FileDownloadWorker(
|
|||
it.value?.payload?.isMatching(accountName, fileId) == true
|
||||
}
|
||||
|
||||
fun isDownloadingFolder(id: Long): Boolean = pendingFolderDownloads.contains(id)
|
||||
|
||||
const val FILE_REMOTE_PATH = "FILE_REMOTE_PATH"
|
||||
const val ACCOUNT_NAME = "ACCOUNT_NAME"
|
||||
const val BEHAVIOUR = "BEHAVIOUR"
|
||||
|
|
@ -170,10 +165,6 @@ class FileDownloadWorker(
|
|||
|
||||
private fun getRequestDownloads(ocFile: OCFile): AbstractList<String> {
|
||||
val files = getFiles(ocFile)
|
||||
val filesPaths = files.map { it.remotePath }
|
||||
val parentIdsOfSubFiles = fileDataStorageManager?.getParentIdsOfSubfiles(filesPaths) ?: listOf()
|
||||
pendingFolderDownloads.addAll(parentIdsOfSubFiles)
|
||||
|
||||
val downloadType = getDownloadType()
|
||||
|
||||
conflictUploadId = inputData.keyValueMap[CONFLICT_UPLOAD_ID] as Long?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.nextcloud.client.account.User
|
||||
import com.nextcloud.client.account.UserAccountManager
|
||||
import com.nextcloud.client.database.entity.toOCUpload
|
||||
import com.nextcloud.client.database.entity.toUploadEntity
|
||||
import com.nextcloud.client.device.BatteryStatus
|
||||
import com.nextcloud.client.device.PowerManagementService
|
||||
import com.nextcloud.client.jobs.BackgroundJobManager
|
||||
|
|
@ -20,6 +23,7 @@ import com.nextcloud.client.network.Connectivity
|
|||
import com.nextcloud.client.network.ConnectivityService
|
||||
import com.nextcloud.utils.extensions.getUploadIds
|
||||
import com.owncloud.android.MainApp
|
||||
import com.owncloud.android.R
|
||||
import com.owncloud.android.datamodel.FileDataStorageManager
|
||||
import com.owncloud.android.datamodel.OCFile
|
||||
import com.owncloud.android.datamodel.UploadsStorageManager
|
||||
|
|
@ -35,13 +39,12 @@ import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
|
|||
import com.owncloud.android.lib.resources.files.model.RemoteFile
|
||||
import com.owncloud.android.operations.RemoveFileOperation
|
||||
import com.owncloud.android.operations.UploadFileOperation
|
||||
import com.owncloud.android.utils.DisplayUtils
|
||||
import com.owncloud.android.utils.FileUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Semaphore
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -85,18 +88,42 @@ class FileUploadHelper {
|
|||
fun buildRemoteName(accountName: String, remotePath: String): String = accountName + remotePath
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries all failed uploads across all user accounts.
|
||||
*
|
||||
* This function retrieves all uploads with the status [UploadStatus.UPLOAD_FAILED], including both
|
||||
* manual uploads and auto uploads. It runs in a background thread (Dispatcher.IO) and ensures
|
||||
* that only one retry operation runs at a time by using a semaphore to prevent concurrent execution.
|
||||
*
|
||||
* Once the failed uploads are retrieved, it calls [retryUploads], which triggers the corresponding
|
||||
* upload workers for each failed upload.
|
||||
*
|
||||
* The function returns `true` if there were any failed uploads to retry and the retry process was
|
||||
* started, or `false` if no uploads were retried.
|
||||
*
|
||||
* @param uploadsStorageManager Provides access to upload data and persistence.
|
||||
* @param connectivityService Checks the current network connectivity state.
|
||||
* @param accountManager Handles user account authentication and selection.
|
||||
* @param powerManagementService Ensures uploads respect power constraints.
|
||||
* @return `true` if any failed uploads were found and retried; `false` otherwise.
|
||||
*/
|
||||
fun retryFailedUploads(
|
||||
uploadsStorageManager: UploadsStorageManager,
|
||||
connectivityService: ConnectivityService,
|
||||
accountManager: UserAccountManager,
|
||||
powerManagementService: PowerManagementService
|
||||
) {
|
||||
if (retryFailedUploadsSemaphore.tryAcquire()) {
|
||||
try {
|
||||
val failedUploads = uploadsStorageManager.failedUploads
|
||||
if (failedUploads == null || failedUploads.isEmpty()) {
|
||||
Log_OC.d(TAG, "Failed uploads are empty or null")
|
||||
return
|
||||
): Boolean {
|
||||
if (!retryFailedUploadsSemaphore.tryAcquire()) {
|
||||
Log_OC.d(TAG, "skipping retryFailedUploads, already running")
|
||||
return true
|
||||
}
|
||||
|
||||
var isUploadStarted = false
|
||||
|
||||
try {
|
||||
getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED) {
|
||||
if (it.isNotEmpty()) {
|
||||
isUploadStarted = true
|
||||
}
|
||||
|
||||
retryUploads(
|
||||
|
|
@ -104,14 +131,14 @@ class FileUploadHelper {
|
|||
connectivityService,
|
||||
accountManager,
|
||||
powerManagementService,
|
||||
failedUploads
|
||||
uploads = it
|
||||
)
|
||||
} finally {
|
||||
retryFailedUploadsSemaphore.release()
|
||||
}
|
||||
} else {
|
||||
Log_OC.d(TAG, "Skip retryFailedUploads since it is already running")
|
||||
} finally {
|
||||
retryFailedUploadsSemaphore.release()
|
||||
}
|
||||
|
||||
return isUploadStarted
|
||||
}
|
||||
|
||||
fun retryCancelledUploads(
|
||||
|
|
@ -120,18 +147,18 @@ class FileUploadHelper {
|
|||
accountManager: UserAccountManager,
|
||||
powerManagementService: PowerManagementService
|
||||
): Boolean {
|
||||
val cancelledUploads = uploadsStorageManager.cancelledUploadsForCurrentAccount
|
||||
if (cancelledUploads == null || cancelledUploads.isEmpty()) {
|
||||
return false
|
||||
var result = false
|
||||
getUploadsByStatus(accountManager.user.accountName, UploadStatus.UPLOAD_CANCELLED) {
|
||||
result = retryUploads(
|
||||
uploadsStorageManager,
|
||||
connectivityService,
|
||||
accountManager,
|
||||
powerManagementService,
|
||||
it
|
||||
)
|
||||
}
|
||||
|
||||
return retryUploads(
|
||||
uploadsStorageManager,
|
||||
connectivityService,
|
||||
accountManager,
|
||||
powerManagementService,
|
||||
cancelledUploads
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
@Suppress("ComplexCondition")
|
||||
|
|
@ -140,35 +167,32 @@ class FileUploadHelper {
|
|||
connectivityService: ConnectivityService,
|
||||
accountManager: UserAccountManager,
|
||||
powerManagementService: PowerManagementService,
|
||||
failedUploads: Array<OCUpload>
|
||||
uploads: Array<OCUpload>
|
||||
): Boolean {
|
||||
var showNotExistMessage = false
|
||||
val isOnline = checkConnectivity(connectivityService)
|
||||
val connectivity = connectivityService.connectivity
|
||||
val batteryStatus = powerManagementService.battery
|
||||
val accountNames = accountManager.accounts.filter { account ->
|
||||
accountManager.getUser(account.name).isPresent
|
||||
}.map { account ->
|
||||
account.name
|
||||
}.toHashSet()
|
||||
|
||||
for (failedUpload in failedUploads) {
|
||||
if (!accountNames.contains(failedUpload.accountName)) {
|
||||
uploadsStorageManager.removeUpload(failedUpload)
|
||||
continue
|
||||
}
|
||||
val uploadsToRetry = mutableListOf<Long>()
|
||||
|
||||
val uploadResult =
|
||||
checkUploadConditions(failedUpload, connectivity, batteryStatus, powerManagementService, isOnline)
|
||||
for (upload in uploads) {
|
||||
val uploadResult = checkUploadConditions(
|
||||
upload,
|
||||
connectivity,
|
||||
batteryStatus,
|
||||
powerManagementService,
|
||||
isOnline
|
||||
)
|
||||
|
||||
if (uploadResult != UploadResult.UPLOADED) {
|
||||
if (failedUpload.lastResult != uploadResult) {
|
||||
if (upload.lastResult != uploadResult) {
|
||||
// Setting Upload status else cancelled uploads will behave wrong, when retrying
|
||||
// Needs to happen first since lastResult wil be overwritten by setter
|
||||
failedUpload.uploadStatus = UploadStatus.UPLOAD_FAILED
|
||||
upload.uploadStatus = UploadStatus.UPLOAD_FAILED
|
||||
|
||||
failedUpload.lastResult = uploadResult
|
||||
uploadsStorageManager.updateUpload(failedUpload)
|
||||
upload.lastResult = uploadResult
|
||||
uploadsStorageManager.updateUpload(upload)
|
||||
}
|
||||
if (uploadResult == UploadResult.FILE_NOT_FOUND) {
|
||||
showNotExistMessage = true
|
||||
|
|
@ -176,15 +200,18 @@ class FileUploadHelper {
|
|||
continue
|
||||
}
|
||||
|
||||
failedUpload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
|
||||
uploadsStorageManager.updateUpload(failedUpload)
|
||||
// Only uploads that passed checks get marked in progress and are collected for scheduling
|
||||
upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
|
||||
uploadsStorageManager.updateUpload(upload)
|
||||
uploadsToRetry.add(upload.uploadId)
|
||||
}
|
||||
|
||||
accountNames.forEach { accountName ->
|
||||
val user = accountManager.getUser(accountName)
|
||||
if (user.isPresent) {
|
||||
backgroundJobManager.startFilesUploadJob(user.get(), failedUploads.getUploadIds(), false)
|
||||
}
|
||||
if (uploadsToRetry.isNotEmpty()) {
|
||||
backgroundJobManager.startFilesUploadJob(
|
||||
accountManager.user,
|
||||
uploadsToRetry.toLongArray(),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
return showNotExistMessage
|
||||
|
|
@ -205,7 +232,7 @@ class FileUploadHelper {
|
|||
showSameFileAlreadyExistsNotification: Boolean = true
|
||||
) {
|
||||
val uploads = localPaths.mapIndexed { index, localPath ->
|
||||
OCUpload(localPath, remotePaths[index], user.accountName).apply {
|
||||
val result = OCUpload(localPath, remotePaths[index], user.accountName).apply {
|
||||
this.nameCollisionPolicy = nameCollisionPolicy
|
||||
isUseWifiOnly = requiresWifi
|
||||
isWhileChargingOnly = requiresCharging
|
||||
|
|
@ -214,47 +241,54 @@ class FileUploadHelper {
|
|||
isCreateRemoteFolder = createRemoteFolder
|
||||
localAction = localBehavior
|
||||
}
|
||||
|
||||
val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity())
|
||||
result.uploadId = id
|
||||
result
|
||||
}
|
||||
uploadsStorageManager.storeUploads(uploads)
|
||||
backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification)
|
||||
}
|
||||
|
||||
fun removeFileUpload(remotePath: String, accountName: String) {
|
||||
try {
|
||||
val user = accountManager.getUser(accountName).get()
|
||||
|
||||
// need to update now table in mUploadsStorageManager,
|
||||
// since the operation will not get to be run by FileUploader#uploadFile
|
||||
uploadsStorageManager.removeUpload(accountName, remotePath)
|
||||
val uploadIds = uploadsStorageManager.getCurrentUploadIds(user.accountName)
|
||||
cancelAndRestartUploadJob(user, uploadIds)
|
||||
} catch (e: NoSuchElementException) {
|
||||
Log_OC.e(TAG, "Error cancelling current upload because user does not exist!: " + e.message)
|
||||
}
|
||||
uploadsStorageManager.uploadDao.deleteByAccountAndRemotePath(accountName, remotePath)
|
||||
}
|
||||
|
||||
fun cancelFileUpload(remotePath: String, accountName: String) {
|
||||
fun updateUploadStatus(remotePath: String, accountName: String, status: UploadStatus) {
|
||||
ioScope.launch {
|
||||
val upload = uploadsStorageManager.getUploadByRemotePath(remotePath)
|
||||
if (upload != null) {
|
||||
cancelFileUploads(listOf(upload), accountName)
|
||||
} else {
|
||||
Log_OC.e(TAG, "Error cancelling current upload because upload does not exist!")
|
||||
}
|
||||
uploadsStorageManager.uploadDao.updateStatus(remotePath, accountName, status.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileUploads(uploads: List<OCUpload>, accountName: String) {
|
||||
for (upload in uploads) {
|
||||
upload.uploadStatus = UploadStatus.UPLOAD_CANCELLED
|
||||
uploadsStorageManager.updateUpload(upload)
|
||||
}
|
||||
|
||||
try {
|
||||
val user = accountManager.getUser(accountName).get()
|
||||
cancelAndRestartUploadJob(user, uploads.getUploadIds())
|
||||
} catch (e: NoSuchElementException) {
|
||||
Log_OC.e(TAG, "Error restarting upload job because user does not exist!: " + e.message)
|
||||
/**
|
||||
* Retrieves uploads filtered by their status, optionally for a specific account.
|
||||
*
|
||||
* This function queries the uploads database asynchronously to obtain a list of uploads
|
||||
* that match the specified [status]. If an [accountName] is provided, only uploads
|
||||
* belonging to that account are retrieved. If [accountName] is `null`, uploads with the
|
||||
* given [status] from **all user accounts** are returned.
|
||||
*
|
||||
* Once the uploads are fetched, the [onCompleted] callback is invoked with the resulting array.
|
||||
*
|
||||
* @param accountName The name of the account to filter uploads by.
|
||||
* If `null`, uploads matching the given [status] from all accounts are returned.
|
||||
* @param status The [UploadStatus] to filter uploads by (e.g., `UPLOAD_FAILED`).
|
||||
* @param nameCollisionPolicy The [NameCollisionPolicy] to filter uploads by (e.g., `SKIP`).
|
||||
* @param onCompleted A callback invoked with the resulting array of [OCUpload] objects.
|
||||
*/
|
||||
fun getUploadsByStatus(
|
||||
accountName: String?,
|
||||
status: UploadStatus,
|
||||
nameCollisionPolicy: NameCollisionPolicy? = null,
|
||||
onCompleted: (Array<OCUpload>) -> Unit
|
||||
) {
|
||||
ioScope.launch {
|
||||
val dao = uploadsStorageManager.uploadDao
|
||||
val result = if (accountName != null) {
|
||||
dao.getUploadsByAccountNameAndStatus(accountName, status.value, nameCollisionPolicy?.serialize())
|
||||
} else {
|
||||
dao.getUploadsByStatus(status.value, nameCollisionPolicy?.serialize())
|
||||
}.map { it.toOCUpload(null) }.toTypedArray()
|
||||
onCompleted(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,26 +300,16 @@ class FileUploadHelper {
|
|||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
fun isUploading(user: User?, file: OCFile?): Boolean {
|
||||
if (user == null || file == null || !backgroundJobManager.isStartFileUploadJobScheduled(user)) {
|
||||
fun isUploading(remotePath: String?, accountName: String?): Boolean {
|
||||
accountName ?: return false
|
||||
if (!backgroundJobManager.isStartFileUploadJobScheduled(accountName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val uploadCompletableFuture = CompletableFuture.supplyAsync {
|
||||
uploadsStorageManager.getUploadByRemotePath(file.remotePath)
|
||||
}
|
||||
return try {
|
||||
val upload = uploadCompletableFuture.get()
|
||||
if (upload != null) {
|
||||
upload.uploadStatus == UploadStatus.UPLOAD_IN_PROGRESS
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: ExecutionException) {
|
||||
false
|
||||
} catch (e: InterruptedException) {
|
||||
false
|
||||
}
|
||||
remotePath ?: return false
|
||||
val upload = uploadsStorageManager.uploadDao.getByRemotePath(remotePath)
|
||||
return upload?.status == UploadStatus.UPLOAD_IN_PROGRESS.value ||
|
||||
FileUploadWorker.isUploading(remotePath, accountName)
|
||||
}
|
||||
|
||||
private fun checkConnectivity(connectivityService: ConnectivityService): Boolean {
|
||||
|
|
@ -364,7 +388,7 @@ class FileUploadHelper {
|
|||
|
||||
val uploads = existingFiles.map { file ->
|
||||
file?.let {
|
||||
OCUpload(file, user).apply {
|
||||
val result = OCUpload(file, user).apply {
|
||||
fileSize = file.fileLength
|
||||
this.nameCollisionPolicy = nameCollisionPolicy
|
||||
isCreateRemoteFolder = true
|
||||
|
|
@ -373,9 +397,12 @@ class FileUploadHelper {
|
|||
isWhileChargingOnly = false
|
||||
uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
|
||||
}
|
||||
|
||||
val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity())
|
||||
result.uploadId = id
|
||||
result
|
||||
}
|
||||
}
|
||||
uploadsStorageManager.storeUploads(uploads)
|
||||
val uploadIds: LongArray = uploads.filterNotNull().map { it.uploadId }.toLongArray()
|
||||
backgroundJobManager.startFilesUploadJob(user, uploadIds, true)
|
||||
}
|
||||
|
|
@ -459,6 +486,14 @@ class FileUploadHelper {
|
|||
return false
|
||||
}
|
||||
|
||||
fun showFileUploadLimitMessage(activity: Activity) {
|
||||
val message = activity.resources.getQuantityString(
|
||||
R.plurals.file_upload_limit_message,
|
||||
MAX_FILE_COUNT
|
||||
)
|
||||
DisplayUtils.showSnackMessage(activity, message)
|
||||
}
|
||||
|
||||
class UploadNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val accountName = intent.getStringExtra(FileUploadWorker.EXTRA_ACCOUNT_NAME)
|
||||
|
|
@ -474,7 +509,9 @@ class FileUploadHelper {
|
|||
return
|
||||
}
|
||||
|
||||
instance().cancelFileUpload(remotePath, accountName)
|
||||
FileUploadWorker.cancelCurrentUpload(remotePath, accountName, onCompleted = {
|
||||
instance().updateUploadStatus(remotePath, accountName, UploadStatus.UPLOAD_CANCELLED)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@
|
|||
*/
|
||||
package com.nextcloud.client.jobs.upload
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.nextcloud.client.account.User
|
||||
import com.nextcloud.client.account.UserAccountManager
|
||||
|
|
@ -21,8 +23,12 @@ import com.nextcloud.client.network.ConnectivityService
|
|||
import com.nextcloud.client.preferences.AppPreferences
|
||||
import com.nextcloud.model.WorkerState
|
||||
import com.nextcloud.model.WorkerStateLiveData
|
||||
import com.nextcloud.utils.ForegroundServiceHelper
|
||||
import com.nextcloud.utils.extensions.getPercent
|
||||
import com.nextcloud.utils.extensions.updateStatus
|
||||
import com.owncloud.android.R
|
||||
import com.owncloud.android.datamodel.FileDataStorageManager
|
||||
import com.owncloud.android.datamodel.ForegroundServiceType
|
||||
import com.owncloud.android.datamodel.ThumbnailsCacheManager
|
||||
import com.owncloud.android.datamodel.UploadsStorageManager
|
||||
import com.owncloud.android.db.OCUpload
|
||||
|
|
@ -34,8 +40,12 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult
|
|||
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
|
||||
import com.owncloud.android.lib.common.utils.Log_OC
|
||||
import com.owncloud.android.operations.UploadFileOperation
|
||||
import com.owncloud.android.ui.notifications.NotificationUtils
|
||||
import com.owncloud.android.utils.ErrorMessageAdapter
|
||||
import com.owncloud.android.utils.theme.ViewThemeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.random.Random
|
||||
|
||||
|
|
@ -51,7 +61,7 @@ class FileUploadWorker(
|
|||
val preferences: AppPreferences,
|
||||
val context: Context,
|
||||
params: WorkerParameters
|
||||
) : Worker(context, params),
|
||||
) : CoroutineWorker(context, params),
|
||||
OnDatatransferProgressListener {
|
||||
|
||||
companion object {
|
||||
|
|
@ -91,19 +101,44 @@ class FileUploadWorker(
|
|||
fun getUploadStartMessage(): String = FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE
|
||||
|
||||
fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE
|
||||
|
||||
fun cancelCurrentUpload(remotePath: String, accountName: String, onCompleted: () -> Unit) {
|
||||
currentUploadFileOperation?.let {
|
||||
if (it.remotePath == remotePath && it.user.accountName == accountName) {
|
||||
it.cancel(ResultCode.USER_CANCELLED)
|
||||
onCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isUploading(remotePath: String?, accountName: String?): Boolean {
|
||||
currentUploadFileOperation?.let {
|
||||
return it.remotePath == remotePath && it.user.accountName == accountName
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var lastPercent = 0
|
||||
private val notificationManager = UploadNotificationManager(context, viewThemeUtils, Random.nextInt())
|
||||
private val notificationId = Random.nextInt()
|
||||
private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId)
|
||||
private val intents = FileUploaderIntents(context)
|
||||
private val fileUploaderDelegate = FileUploaderDelegate()
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun doWork(): Result = try {
|
||||
override suspend fun doWork(): Result = try {
|
||||
Log_OC.d(TAG, "FileUploadWorker started")
|
||||
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
|
||||
val workerName = BackgroundJobManagerImpl.formatClassTag(this::class)
|
||||
backgroundJobManager.logStartOfWorker(workerName)
|
||||
|
||||
val notificationTitle = notificationManager.currentOperationTitle
|
||||
?: context.getString(R.string.foreground_service_upload)
|
||||
val notification = createNotification(notificationTitle)
|
||||
updateForegroundInfo(notification)
|
||||
|
||||
val result = uploadFiles()
|
||||
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
|
||||
backgroundJobManager.logEndOfWorker(workerName, result)
|
||||
notificationManager.dismissNotification()
|
||||
if (result == Result.success()) {
|
||||
setIdleWorkerState()
|
||||
|
|
@ -111,17 +146,37 @@ class FileUploadWorker(
|
|||
result
|
||||
} catch (t: Throwable) {
|
||||
Log_OC.e(TAG, "Error caught at FileUploadWorker $t")
|
||||
cleanup()
|
||||
Result.failure()
|
||||
}
|
||||
|
||||
override fun onStopped() {
|
||||
private suspend fun updateForegroundInfo(notification: Notification) {
|
||||
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
|
||||
notificationId,
|
||||
notification,
|
||||
ForegroundServiceType.DataSync
|
||||
)
|
||||
setForeground(foregroundInfo)
|
||||
}
|
||||
|
||||
private fun createNotification(title: String): Notification =
|
||||
NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD)
|
||||
.setContentTitle(title)
|
||||
.setSmallIcon(R.drawable.uploads)
|
||||
.setOngoing(true)
|
||||
.setSound(null)
|
||||
.setVibrate(null)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
|
||||
private fun cleanup() {
|
||||
Log_OC.e(TAG, "FileUploadWorker stopped")
|
||||
|
||||
setIdleWorkerState()
|
||||
currentUploadFileOperation?.cancel(null)
|
||||
notificationManager.dismissNotification()
|
||||
|
||||
super.onStopped()
|
||||
}
|
||||
|
||||
private fun setWorkerState(user: User?) {
|
||||
|
|
@ -133,36 +188,36 @@ class FileUploadWorker(
|
|||
}
|
||||
|
||||
@Suppress("ReturnCount", "LongMethod")
|
||||
private fun uploadFiles(): Result {
|
||||
private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) {
|
||||
val accountName = inputData.getString(ACCOUNT)
|
||||
if (accountName == null) {
|
||||
Log_OC.e(TAG, "accountName is null")
|
||||
return Result.failure()
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
val uploadIds = inputData.getLongArray(UPLOAD_IDS)
|
||||
if (uploadIds == null) {
|
||||
Log_OC.e(TAG, "uploadIds is null")
|
||||
return Result.failure()
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1)
|
||||
if (currentBatchIndex == -1) {
|
||||
Log_OC.e(TAG, "currentBatchIndex is -1, cancelling")
|
||||
return Result.failure()
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1)
|
||||
if (totalUploadSize == -1) {
|
||||
Log_OC.e(TAG, "totalUploadSize is -1, cancelling")
|
||||
return Result.failure()
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// since worker's policy is append or replace and account name comes from there no need check in the loop
|
||||
val optionalUser = userAccountManager.getUser(accountName)
|
||||
if (!optionalUser.isPresent) {
|
||||
Log_OC.e(TAG, "User not found for account: $accountName")
|
||||
return Result.failure()
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
val user = optionalUser.get()
|
||||
|
|
@ -172,21 +227,19 @@ class FileUploadWorker(
|
|||
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
|
||||
|
||||
for ((index, upload) in uploads.withIndex()) {
|
||||
ensureActive()
|
||||
|
||||
if (preferences.isGlobalUploadPaused) {
|
||||
Log_OC.d(TAG, "Upload is paused, skip uploading files!")
|
||||
notificationManager.notifyPaused(
|
||||
intents.notificationStartIntent(null)
|
||||
)
|
||||
return Result.success()
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
if (canExitEarly()) {
|
||||
notificationManager.showConnectionErrorNotification()
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (isStopped) {
|
||||
continue
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
setWorkerState(user)
|
||||
|
|
@ -203,12 +256,16 @@ class FileUploadWorker(
|
|||
totalUploadSize = totalUploadSize
|
||||
)
|
||||
|
||||
val result = upload(operation, user, client)
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
upload(operation, user, client)
|
||||
}
|
||||
val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName)
|
||||
uploadsStorageManager.updateStatus(entity, result.isSuccess)
|
||||
currentUploadFileOperation = null
|
||||
sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
private fun sendUploadFinishEvent(
|
||||
|
|
@ -346,6 +403,10 @@ class FileUploadWorker(
|
|||
return
|
||||
}
|
||||
|
||||
if (uploadResult.code == ResultCode.USER_CANCELLED) {
|
||||
return
|
||||
}
|
||||
|
||||
notificationManager.run {
|
||||
val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(
|
||||
uploadResult,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
package com.nextcloud.client.logger.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
|
@ -17,6 +18,7 @@ import com.nextcloud.client.logger.LogsRepository
|
|||
import com.owncloud.android.R
|
||||
import javax.inject.Inject
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
class LogsViewModel @Inject constructor(
|
||||
private val context: Context,
|
||||
clock: Clock,
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ class WhatsNewActivity :
|
|||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
onFinish()
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ public interface AppPreferences {
|
|||
|
||||
boolean isShowHiddenFilesEnabled();
|
||||
void setShowHiddenFilesEnabled(boolean enabled);
|
||||
|
||||
boolean isSortFoldersBeforeFiles();
|
||||
void setSortFoldersBeforeFiles(boolean enabled);
|
||||
|
||||
boolean isSortFavoritesFirst();
|
||||
void setSortFavoritesFirst(boolean enabled);
|
||||
|
||||
boolean isShowEcosystemApps();
|
||||
void setShowEcosystemApps(boolean enabled);
|
||||
|
|
@ -344,10 +350,6 @@ public interface AppPreferences {
|
|||
|
||||
long getPhotoSearchTimestamp();
|
||||
|
||||
boolean isPowerCheckDisabled();
|
||||
|
||||
void setPowerCheckDisabled(boolean value);
|
||||
|
||||
void increasePinWrongAttempts();
|
||||
|
||||
void resetPinWrongAttempts();
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ public final class AppPreferencesImpl implements AppPreferences {
|
|||
private static final String PREF__INSTANT_UPLOADING = "instant_uploading";
|
||||
private static final String PREF__INSTANT_VIDEO_UPLOADING = "instant_video_uploading";
|
||||
private static final String PREF__SHOW_HIDDEN_FILES = "show_hidden_files_pref";
|
||||
private static final String PREF__SORT_FOLDERS_BEFORE_FILES = "sort_folders_before_files";
|
||||
private static final String PREF__SORT_FAVORITES_FIRST = "sort_favorites_first";
|
||||
private static final String PREF__SHOW_ECOSYSTEM_APPS = "show_ecosystem_apps";
|
||||
private static final String PREF__LEGACY_CLEAN = "legacyClean";
|
||||
private static final String PREF__KEYS_MIGRATION = "keysMigration";
|
||||
|
|
@ -88,7 +90,6 @@ public final class AppPreferencesImpl implements AppPreferences {
|
|||
private static final String PREF__SELECTED_ACCOUNT_NAME = "select_oc_account";
|
||||
private static final String PREF__MIGRATED_USER_ID = "migrated_user_id";
|
||||
private static final String PREF__PHOTO_SEARCH_TIMESTAMP = "photo_search_timestamp";
|
||||
private static final String PREF__POWER_CHECK_DISABLED = "power_check_disabled";
|
||||
private static final String PREF__PIN_BRUTE_FORCE_COUNT = "pin_brute_force_count";
|
||||
private static final String PREF__UID_PID = "uid_pid";
|
||||
|
||||
|
|
@ -229,6 +230,26 @@ public final class AppPreferencesImpl implements AppPreferences {
|
|||
preferences.edit().putBoolean(PREF__SHOW_HIDDEN_FILES, enabled).apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSortFoldersBeforeFiles() {
|
||||
return preferences.getBoolean(PREF__SORT_FOLDERS_BEFORE_FILES, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSortFoldersBeforeFiles(boolean enabled) {
|
||||
preferences.edit().putBoolean(PREF__SORT_FOLDERS_BEFORE_FILES, enabled).apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSortFavoritesFirst() {
|
||||
return preferences.getBoolean(PREF__SORT_FAVORITES_FIRST, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSortFavoritesFirst(boolean enabled) {
|
||||
preferences.edit().putBoolean(PREF__SORT_FAVORITES_FIRST, enabled).apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShowEcosystemApps() {
|
||||
return preferences.getBoolean(PREF__SHOW_ECOSYSTEM_APPS, true);
|
||||
|
|
@ -689,16 +710,6 @@ public final class AppPreferencesImpl implements AppPreferences {
|
|||
return preferenceName + "_" + folderIdString;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPowerCheckDisabled() {
|
||||
return preferences.getBoolean(PREF__POWER_CHECK_DISABLED, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPowerCheckDisabled(boolean value) {
|
||||
preferences.edit().putBoolean(PREF__POWER_CHECK_DISABLED, value).apply();
|
||||
}
|
||||
|
||||
public void increasePinWrongAttempts() {
|
||||
int count = preferences.getInt(PREF__PIN_BRUTE_FORCE_COUNT, 0);
|
||||
preferences.edit().putInt(PREF__PIN_BRUTE_FORCE_COUNT, count + 1).apply();
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ enum class SearchResultEntryType {
|
|||
Unknown;
|
||||
|
||||
fun iconId(): Int = when (this) {
|
||||
CalendarEvent -> R.drawable.file_calendar
|
||||
Folder -> R.drawable.folder
|
||||
Note -> R.drawable.ic_edit
|
||||
Contact -> R.drawable.file_vcard
|
||||
CalendarEvent -> R.drawable.file_calendar
|
||||
Deck -> R.drawable.ic_deck
|
||||
else -> R.drawable.ic_find_in_page
|
||||
Unknown -> R.drawable.ic_find_in_page
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ class ChooseAccountDialogFragment :
|
|||
return builder.create()
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
accountManager = (activity as BaseActivity).userAccountManager
|
||||
|
|
|
|||
|
|
@ -335,6 +335,7 @@ class SetStatusMessageBottomSheet(val user: User, val currentStatus: Status?) :
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
@VisibleForTesting
|
||||
fun setPredefinedStatus(predefinedStatus: ArrayList<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 com.nextcloud.client.assistant.AssistantScreen
|
||||
import com.nextcloud.client.assistant.AssistantViewModel
|
||||
import com.nextcloud.client.assistant.repository.AssistantRepository
|
||||
import com.nextcloud.client.assistant.repository.local.AssistantLocalRepositoryImpl
|
||||
import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositoryImpl
|
||||
import com.nextcloud.client.database.NextcloudDatabase
|
||||
import com.nextcloud.common.NextcloudClient
|
||||
import com.nextcloud.utils.extensions.getSerializableArgument
|
||||
import com.owncloud.android.R
|
||||
|
|
@ -79,10 +81,14 @@ class ComposeActivity : DrawerActivity() {
|
|||
isChecked = true
|
||||
}
|
||||
|
||||
val dao = NextcloudDatabase.instance().assistantDao()
|
||||
|
||||
nextcloudClient?.let { client ->
|
||||
AssistantScreen(
|
||||
viewModel = AssistantViewModel(
|
||||
repository = AssistantRepository(client, capabilities)
|
||||
accountName = userAccountManager.user.accountName,
|
||||
remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities),
|
||||
localRepository = AssistantLocalRepositoryImpl(dao)
|
||||
),
|
||||
activity = this,
|
||||
capability = capabilities
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ enum class FileAction(
|
|||
|
||||
// Uploads and downloads
|
||||
DOWNLOAD_FILE(R.id.action_download_file, R.string.filedetails_download, R.drawable.ic_cloud_download),
|
||||
SYNC_FILE(R.id.action_sync_file, R.string.filedetails_sync_file, R.drawable.ic_cloud_sync_on),
|
||||
CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync, R.drawable.ic_cloud_sync_off),
|
||||
DOWNLOAD_FOLDER(R.id.action_sync_file, R.string.filedetails_sync_file, R.drawable.ic_sync),
|
||||
CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync, R.drawable.ic_sync_off),
|
||||
|
||||
// File sharing
|
||||
EXPORT_FILE(R.id.action_export_file, R.string.filedetails_export, R.drawable.ic_export),
|
||||
|
|
@ -84,7 +84,7 @@ enum class FileAction(
|
|||
SEND_SHARE_FILE,
|
||||
SEND_FILE,
|
||||
OPEN_FILE_WITH,
|
||||
SYNC_FILE,
|
||||
DOWNLOAD_FOLDER,
|
||||
CANCEL_SYNC,
|
||||
SELECT_ALL,
|
||||
SELECT_NONE,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,15 @@
|
|||
package com.nextcloud.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.nextcloud.utils.extensions.toFile
|
||||
import com.owncloud.android.lib.common.utils.Log_OC
|
||||
import com.owncloud.android.utils.BitmapUtils.calculateSampleFactor
|
||||
|
||||
private const val TAG = "BitmapExtension"
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun Bitmap.allocationKilobyte(): Int = allocationByteCount.div(1024)
|
||||
|
|
@ -38,3 +46,115 @@ fun Bitmap.scaleUntil(targetKB: Int): Bitmap {
|
|||
val scaledBitmap = scale(width, height)
|
||||
return scaledBitmap.scaleUntil(targetKB)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates and/or flips a [Bitmap] according to an EXIF orientation constant.
|
||||
*
|
||||
* Needed because loading bitmaps directly may ignore EXIF metadata with some devices,
|
||||
* resulting in incorrectly displayed images.
|
||||
*
|
||||
* This function uses a [Matrix] transformation to adjust the image so that it
|
||||
* appears upright when displayed. It supports all standard EXIF orientations,
|
||||
* including mirrored and rotated cases.
|
||||
*
|
||||
* The original bitmap will be recycled if a new one is successfully created.
|
||||
* If the device runs out of memory during the transformation, the original bitmap
|
||||
* is returned unchanged.
|
||||
*
|
||||
* @receiver The [Bitmap] to rotate or flip. Can be `null`.
|
||||
* @param orientation One of the [ExifInterface] orientation constants, such as
|
||||
* [ExifInterface.ORIENTATION_ROTATE_90] or [ExifInterface.ORIENTATION_FLIP_HORIZONTAL].
|
||||
* @return The correctly oriented [Bitmap], or `null` if the receiver was `null`.
|
||||
*
|
||||
* @see ExifInterface
|
||||
* @see Matrix
|
||||
*/
|
||||
@Suppress("MagicNumber", "ReturnCount")
|
||||
fun Bitmap?.rotateBitmapViaExif(orientation: Int): Bitmap? {
|
||||
if (this == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_NORMAL -> return this
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
matrix.setRotate(180f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.setRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.setRotate(-90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
|
||||
else -> return this
|
||||
}
|
||||
|
||||
return try {
|
||||
val rotated = Bitmap.createBitmap(
|
||||
this,
|
||||
0,
|
||||
0,
|
||||
this.width,
|
||||
this.height,
|
||||
matrix,
|
||||
true
|
||||
)
|
||||
|
||||
// release original if a new one was created
|
||||
if (rotated != this) {
|
||||
this.recycle()
|
||||
}
|
||||
|
||||
rotated
|
||||
} catch (_: OutOfMemoryError) {
|
||||
Log_OC.e("BitmapExtension", "rotating bitmap, out of memory exception")
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a bitmap from a file path while minimizing memory usage.
|
||||
*
|
||||
* This function first checks if the file exists (via [toFile]), then performs following steps:
|
||||
*
|
||||
* 1. Reads image dimensions using [BitmapFactory.Options.inJustDecodeBounds] without allocating memory.
|
||||
* 2. Calculates a sampling factor with [calculateSampleFactor] to scale down large images efficiently.
|
||||
* 3. Decodes the actual bitmap using the computed sample size.
|
||||
*
|
||||
* @param srcPath Absolute path to the image file.
|
||||
* @param reqWidth Desired width in pixels of the output bitmap.
|
||||
* @param reqHeight Desired height in pixels of the output bitmap.
|
||||
* @return The decoded [Bitmap], or `null` if the file does not exist or decoding fails.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun decodeSampledBitmapFromFile(srcPath: String?, reqWidth: Int, reqHeight: Int): Bitmap? {
|
||||
// check existence of file
|
||||
srcPath?.toFile() ?: return null
|
||||
|
||||
// Read image dimensions without allocating memory just to get pixels
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(srcPath, options)
|
||||
|
||||
// Calculate sampling factor
|
||||
options.inSampleSize = calculateSampleFactor(options, reqWidth, reqHeight)
|
||||
options.inJustDecodeBounds = false
|
||||
|
||||
// Decode actual bitmap
|
||||
return try {
|
||||
BitmapFactory.decodeFile(srcPath, options)
|
||||
} catch (e: Exception) {
|
||||
Log_OC.e(TAG, "exception during decoding path: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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