From 75813788aaa17995bd3de03e3d5b22e1ea3de59b Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Wed, 7 Jan 2026 11:59:20 +0100 Subject: [PATCH 001/125] Manually bump library Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d5782bd13cd..d386304e7c28 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidCommonLibraryVersion = "0.31.0" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" -androidLibraryVersion = "827db94ca661d39ca7fae5c608eab1282b629b84" +androidLibraryVersion = "b711becb0fe4fa3b09a0ea83a498579310cc8e69" androidPluginVersion = '8.13.2' androidsvgVersion = "1.4" androidxMediaVersion = "1.5.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a1b6736ac9de..26b454512fca 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -20131,6 +20131,14 @@ + + + + + + + + From c2fddba560507dc3c34c8f8170e10062f4060d1c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:05:29 +0000 Subject: [PATCH 002/125] chore(deps): update dependency fastlane to v2.230.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: Raphael Vieira --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5b7fdaf6546f..ed8611d08318 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,8 +8,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1200.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1201.0) + aws-sdk-core (3.241.2) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,11 +17,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.119.0) + aws-sdk-core (~> 3, >= 3.241.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.209.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.210.1) + aws-sdk-core (~> 3, >= 3.241.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) From db8dfe838af210366ed2e19ceaf141529a500fb2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:04:52 +0000 Subject: [PATCH 003/125] chore(deps): update nextcloud/pr-feedback-action digest to 5227c55 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: Raphael Vieira --- .github/workflows/pr-feedback.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-feedback.yml b/.github/workflows/pr-feedback.yml index 140f31cd25dc..a3a2d1296c7a 100644 --- a/.github/workflows/pr-feedback.yml +++ b/.github/workflows/pr-feedback.yml @@ -36,7 +36,7 @@ jobs: blocklist=$(curl https://raw.githubusercontent.com/nextcloud/.github/master/non-community-usernames.txt | paste -s -d, -) echo "blocklist=$blocklist" >> "$GITHUB_OUTPUT" - - uses: nextcloud/pr-feedback-action@908074dcb5de6d4f68011bf1a4522ad061996791 # main + - uses: nextcloud/pr-feedback-action@5227c55be184087d0aef6338bee210d8620b6297 # main with: feedback-message: | Hello there, From aeac903d328e1b7c28a2efe0fcdeb7e0c23d5de6 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 30 Dec 2025 08:32:44 +0100 Subject: [PATCH 004/125] FolderPickerActivity: show correct folder Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- .../android/ui/adapter/helper/OCFileListAdapterHelper.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt b/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt index bca01ae9caba..b2c3ece5a6f1 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt @@ -36,6 +36,9 @@ class OCFileListAdapterHelper { userId: String, onComplete: (List, FileSortOrder) -> Unit ) { + // cancel previous job to not have two jobs running + job?.cancel() + job = scope.launch { val (sortedList, sortOrder) = prepareFileList( directory, From dff596101fab0e5f546959482dfa7cee98f30cb4 Mon Sep 17 00:00:00 2001 From: alperozturk Date: Fri, 28 Nov 2025 15:22:01 +0100 Subject: [PATCH 005/125] fix: battery optimization check Signed-off-by: alperozturk Signed-off-by: Raphael Vieira --- .../utils/BatteryOptimizationHelper.kt | 46 ++++++++++++ .../ui/activity/SyncedFoldersActivity.kt | 70 ++++++++----------- 2 files changed, 75 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/utils/BatteryOptimizationHelper.kt diff --git a/app/src/main/java/com/nextcloud/utils/BatteryOptimizationHelper.kt b/app/src/main/java/com/nextcloud/utils/BatteryOptimizationHelper.kt new file mode 100644 index 000000000000..eb4add0298c1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/BatteryOptimizationHelper.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import android.provider.Settings +import androidx.core.net.toUri +import com.owncloud.android.lib.common.utils.Log_OC + +object BatteryOptimizationHelper { + + private const val TAG = "BatteryOptimizationHelper" + + fun isBatteryOptimizationEnabled(context: Context): Boolean { + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return !pm.isIgnoringBatteryOptimizations(context.packageName) + } + + @Suppress("TooGenericExceptionCaught") + @SuppressLint("BatteryLife") + fun openBatteryOptimizationSettings(context: Context) { + try { + val intent = Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:${context.packageName}".toUri() + ) + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + // Fallback to generic battery optimization settings + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } + } catch (e: Exception) { + Log_OC.d(TAG, "open battery optimization settings: ", e) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt index b26797716930..a25d0a23402c 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt @@ -14,15 +14,12 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import android.os.Looper -import android.os.PowerManager -import android.provider.Settings import android.text.TextUtils import android.view.Menu import android.view.MenuItem import android.view.View import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -36,10 +33,10 @@ import com.nextcloud.client.jobs.MediaFoldersDetectionWork import com.nextcloud.client.jobs.NotificationWork import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.preferences.SubFolderRule +import com.nextcloud.utils.BatteryOptimizationHelper import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.isDialogFragmentReady import com.nextcloud.utils.extensions.setVisibleIf -import com.owncloud.android.BuildConfig import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.StoragePermissionWarningBannerBinding @@ -577,7 +574,7 @@ class SyncedFoldersActivity : } if (syncedFolderDisplayItem.isEnabled) { backgroundJobManager.startAutoUploadImmediately(syncedFolderDisplayItem, overridePowerSaving = false) - showBatteryOptimizationInfo() + showBatteryOptimizationDialogIfNeeded() } } @@ -711,7 +708,7 @@ class SyncedFoldersActivity : } dialogFragment = null if (syncedFolder.isEnabled) { - showBatteryOptimizationInfo() + showBatteryOptimizationDialogIfNeeded() } } @@ -835,44 +832,35 @@ class SyncedFoldersActivity : } } - private fun showBatteryOptimizationInfo() { - if (checkIfBatteryOptimizationEnabled()) { - val alertDialogBuilder = MaterialAlertDialogBuilder(this, R.style.Theme_ownCloud_Dialog) - .setTitle(getString(R.string.battery_optimization_title)) - .setMessage(getString(R.string.battery_optimization_message)) - .setPositiveButton(getString(R.string.battery_optimization_disable)) { _, _ -> - // show instant upload - @SuppressLint("BatteryLife") - val intent = Intent( - Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - ("package:" + BuildConfig.APPLICATION_ID).toUri() - ) - if (intent.resolveActivity(packageManager) != null) { - startActivity(intent) - } - } - .setNeutralButton(getString(R.string.battery_optimization_close)) { dialog, _ -> dialog.dismiss() } - .setIcon(R.drawable.ic_battery_alert) - if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - val alertDialog = alertDialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - alertDialog.getButton(AlertDialog.BUTTON_POSITIVE), - alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL) - ) - } + private fun showBatteryOptimizationDialogIfNeeded() { + if (!BatteryOptimizationHelper.isBatteryOptimizationEnabled(this)) { + Log_OC.d(TAG, "battery optimization is disabled") + return } + + showBatteryOptimizationDialog() } - /** - * Check if battery optimization is enabled. If unknown, fallback to true. - * - * @return true if battery optimization is enabled - */ - private fun checkIfBatteryOptimizationEnabled(): Boolean { - val powerManager = getSystemService(POWER_SERVICE) as PowerManager? - return when { - powerManager != null -> !powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) - else -> !appInfo.isDebugBuild + private fun showBatteryOptimizationDialog() { + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + Log_OC.w(TAG, "Activity not resumed, skipping battery dialog") + return } + + val dialog = MaterialAlertDialogBuilder(this, R.style.Theme_ownCloud_Dialog) + .setTitle(R.string.battery_optimization_title) + .setMessage(R.string.battery_optimization_message) + .setPositiveButton(R.string.battery_optimization_disable) { _, _ -> + BatteryOptimizationHelper.openBatteryOptimizationSettings(this) + } + .setNeutralButton(R.string.battery_optimization_close, null) + .setIcon(R.drawable.ic_battery_alert) + + val alertDialog = dialog.show() + + viewThemeUtils.platform.colorTextButtons( + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE), + alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL) + ) } } From abe6d648bc459d6d43a1949c66fe2a64d89804f2 Mon Sep 17 00:00:00 2001 From: Gorlesunilkumar Date: Wed, 26 Nov 2025 00:50:30 +0530 Subject: [PATCH 006/125] fix(calendar): correct ICS event formatting and time zone usage The ICS event file has been updated to follow proper RFC 5545 formatting. Changes: - Added a valid DESCRIPTION field instead of an empty one - Unified DTSTART and DTEND to use the same TZID (Europe/Berlin) - Ensured the event duration is correct and consistent - Cleaned up extra blank lines to avoid parsing issues - Retained the complete VTIMEZONE block for compatibility with Nextcloud, Google Calendar, and iOS This improves interoperability with calendar clients and prevents parsing errors caused by mixed time formats. Signed-off-by: Gorlesunilkumar Signed-off-by: Raphael Vieira --- app/src/androidTest/assets/calendar.ics | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/assets/calendar.ics b/app/src/androidTest/assets/calendar.ics index d505884c3fd5..421cfeb464f5 100644 --- a/app/src/androidTest/assets/calendar.ics +++ b/app/src/androidTest/assets/calendar.ics @@ -116,12 +116,14 @@ BEGIN:VEVENT DTSTAMP:20210820T083606Z UID:16294485666795adb8b4b08e94d3cb4445e1e3ee18fd9@nextcloud.com SUMMARY:Test event -DESCRIPTION: +DESCRIPTION:Test event + ORGANIZER:mailto:dummy@gmail.com LOCATION: STATUS:CONFIRMED DTSTART;TZID=Europe/Berlin:20210806T090000 -DTEND:20210806T080000Z +DTEND;TZID=Europe/Berlin:20210806T100000 + BEGIN:VALARM TRIGGER:-PT30M ACTION:DISPLAY From 6c1fb0f414bcc02ca39d5321f691f4cc509cc5bd Mon Sep 17 00:00:00 2001 From: nextcloud-android-bot Date: Fri, 9 Jan 2026 02:41:29 +0000 Subject: [PATCH 007/125] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'.githu?= =?UTF-8?q?b/workflows/'=20with=20remote=20'config/workflows/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nextcloud-android-bot Signed-off-by: Raphael Vieira --- .github/workflows/QA_keystore.jks.license | 2 +- .github/workflows/analysis.yml | 4 +--- .github/workflows/codeql.yml | 4 ++-- .github/workflows/lib.sh | 2 +- .github/workflows/lib.sh.license | 2 +- .github/workflows/qa.yml | 6 +++--- .github/workflows/uploadArtifact.sh | 2 +- 7 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/QA_keystore.jks.license b/.github/workflows/QA_keystore.jks.license index 7ec214431369..f070b8a4c019 100644 --- a/.github/workflows/QA_keystore.jks.license +++ b/.github/workflows/QA_keystore.jks.license @@ -1,2 +1,2 @@ SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors -SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 4b03ffe92c2f..d38ab46aec69 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -27,7 +27,6 @@ concurrency: jobs: analysis: runs-on: ubuntu-latest - timeout-minutes: 60 steps: - name: Disabled on forks if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} @@ -71,9 +70,8 @@ jobs: run: | mkdir -p "$HOME/.gradle" { - echo "org.gradle.jvmargs=-Xmx5g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" + echo "org.gradle.jvmargs=-Xmx1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8" echo "org.gradle.configureondemand=true" - echo "org.gradle.configuration-cache=false" echo "kapt.incremental.apt=true" } > "$HOME/.gradle/gradle.properties" scripts/analysis/analysis-wrapper.sh "${{ steps.get-vars.outputs.branch }}" "${{ secrets.LOG_USERNAME }}" "${{ secrets.LOG_PASSWORD }}" "$GITHUB_RUN_NUMBER" "${{ steps.get-vars.outputs.pr }}" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 36d9e22b6a24..b4ec67f99f8b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: - name: Assemble run: | mkdir -p "$HOME/.gradle" - echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" > "$HOME/.gradle/gradle.properties" - ./gradlew assembleDebug + echo "org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" > "$HOME/.gradle/gradle.properties" + ./gradlew --no-daemon assembleDebug - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 diff --git a/.github/workflows/lib.sh b/.github/workflows/lib.sh index 42f4b55b3efe..3bb8b10f7930 100644 --- a/.github/workflows/lib.sh +++ b/.github/workflows/lib.sh @@ -2,7 +2,7 @@ # # SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors # SPDX-FileCopyrightText: 2022 Álvaro Brey -# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +# SPDX-License-Identifier: AGPL-3.0-or-later # ## This file is intended to be sourced by other scripts diff --git a/.github/workflows/lib.sh.license b/.github/workflows/lib.sh.license index 162c96150eb5..23bad5cb8a9c 100644 --- a/.github/workflows/lib.sh.license +++ b/.github/workflows/lib.sh.license @@ -1,2 +1,2 @@ SPDX-FileCopyrightText: 2019-2025 Nextcloud GmbH and Nextcloud contributors -SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 378bcbe28c4e..ef51624feed4 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -46,10 +46,10 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" > "$HOME/.gradle/gradle.properties" echo "org.gradle.caching=true; org.gradle.parallel=true; org.gradle.configureondemand=true; kapt.incremental.apt=true" >> "$HOME/.gradle/gradle.properties" - sed -i "/qa/,/\}/ s/versionCode.*/versionCode = ${{ github.event.number }}/" app/build.gradle.kts - sed -i "/qa/,/\}/ s/versionName.*/versionName = \"${{ github.event.number }}\"/" app/build.gradle.kts + sed -i "/qa/,/\}/ s/versionCode .*/versionCode ${{github.event.number}} /" "app/build.gradle" + sed -i "/qa/,/\}/ s/versionName .*/versionName \"${{github.event.number}}\"/" "app/build.gradle" ./gradlew assembleQaDebug $(find /usr/local/lib/android/sdk/build-tools/*/apksigner | sort | tail -n1) sign --ks-pass pass:"$KS_PASS" --key-pass pass:"$KEY_PASS" --ks-key-alias key0 --ks ".github/workflows/QA_keystore.jks" app/build/outputs/apk/qa/debug/*qa-debug*.apk .github/workflows/uploadArtifact.sh "$LOG_USERNAME" "$LOG_PASSWORD" "${{github.event.number}}" "${{github.event.number}}" "$GITHUB_TOKEN" diff --git a/.github/workflows/uploadArtifact.sh b/.github/workflows/uploadArtifact.sh index fa263064bf66..bf96572045ab 100755 --- a/.github/workflows/uploadArtifact.sh +++ b/.github/workflows/uploadArtifact.sh @@ -3,7 +3,7 @@ # # SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors # SPDX-FileCopyrightText: 2019-2022 Tobias Kaminsky -# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only +# SPDX-License-Identifier: AGPL-3.0-or-later # #1: LOG_USERNAME From 3c8574ab37051ee8462024802c20176d0ea6d666 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Fri, 9 Jan 2026 09:51:38 +0100 Subject: [PATCH 008/125] we us kts Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- .github/workflows/qa.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index ef51624feed4..ab1e890188f8 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -48,8 +48,8 @@ jobs: mkdir -p "$HOME/.gradle" echo "org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" > "$HOME/.gradle/gradle.properties" echo "org.gradle.caching=true; org.gradle.parallel=true; org.gradle.configureondemand=true; kapt.incremental.apt=true" >> "$HOME/.gradle/gradle.properties" - sed -i "/qa/,/\}/ s/versionCode .*/versionCode ${{github.event.number}} /" "app/build.gradle" - sed -i "/qa/,/\}/ s/versionName .*/versionName \"${{github.event.number}}\"/" "app/build.gradle" + sed -i "/qa/,/\}/ s/versionCode .*/versionCode = ${{github.event.number}} /" "app/build.gradle.kts" + sed -i "/qa/,/\}/ s/versionName .*/versionName = \"${{github.event.number}}\"/" "app/build.gradle.kts" ./gradlew assembleQaDebug $(find /usr/local/lib/android/sdk/build-tools/*/apksigner | sort | tail -n1) sign --ks-pass pass:"$KS_PASS" --key-pass pass:"$KEY_PASS" --ks-key-alias key0 --ks ".github/workflows/QA_keystore.jks" app/build/outputs/apk/qa/debug/*qa-debug*.apk .github/workflows/uploadArtifact.sh "$LOG_USERNAME" "$LOG_PASSWORD" "${{github.event.number}}" "${{github.event.number}}" "$GITHUB_TOKEN" From 7e772d9f67d44a2b82dcefd31ca251b55edc5e08 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Sun, 11 Jan 2026 21:03:33 +0000 Subject: [PATCH 009/125] Added function to parallelise uploads. Still need to address non-thread safe vars and make sure UI updates accordingly Signed-off-by: Raphael Vieira --- .../client/jobs/upload/FileUploadWorker.kt | 103 +++++++++++++++++- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 275dce4b470c..d7522364f445 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -43,8 +43,14 @@ 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.theme.ViewThemeUtils +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import java.io.File import kotlin.random.Random @@ -253,7 +259,95 @@ class FileUploadWorker( val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) - for ((index, upload) in uploads.withIndex()) { + return@withContext parallelUpload(uploads, user, previouslyUploadedFileSize, totalUploadSize, client, accountName) + + // return@withContext sequentialUpload(uploads, user, previouslyUploadedFileSize, totalUploadSize, client, accountName) + } + + + private suspend fun parallelUpload( + uploads: List?, + user: User, + previouslyUploadedFileSize: Int, + totalUploadSize: Int, + client: OwnCloudClient, + accountName: String + ): Result { + val semaphore = Semaphore(5) // Limit to 5 parallel uploads + var quotaExceeded = false + + coroutineScope { + for ((index, upload) in uploads?.withIndex()!!) { + if (quotaExceeded) break + ensureActive() + + launch { + semaphore.withPermit { + if (quotaExceeded || isStopped) return@launch + + if (preferences.isGlobalUploadPaused) { + Log_OC.d(TAG, "Upload is paused, skip uploading files!") + notificationManager.notifyPaused(intents.openUploadListIntent(null)) + return@launch + } + + if (canExitEarly()) { + notificationManager.showConnectionErrorNotification() + return@launch + } + + setWorkerState(user) + val operation = createUploadFileOperation(upload, user) + + // NOTE: currentUploadFileOperation is a companion property. + // Parallelizing will cause race conditions here. + // You should ideally move this to a thread-safe collection if needed. + currentUploadFileOperation = operation + + val currentIndex = (index + 1) + val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) + + // Synchronize notification updates if they aren't thread-safe + synchronized(notificationManager) { + notificationManager.prepareForStart( + operation, + startIntent = intents.openUploadListIntent(operation), + currentUploadIndex = currentUploadIndex, + totalUploadSize = totalUploadSize + ) + } + + val result = upload(operation, user, client) + + val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) + uploadsStorageManager.updateStatus(entity, result.isSuccess) + + if (result.code == ResultCode.QUOTA_EXCEEDED) { + Log_OC.w(TAG, "Quota exceeded, stopping uploads") + notificationManager.showQuotaExceedNotification(operation) + quotaExceeded = true + this@coroutineScope.cancel("Quota exceeded") + return@launch + } + + sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) + } + } + } + } + + return if (quotaExceeded) Result.failure() else Result.success() + } + + private suspend fun CoroutineScope.sequentialUpload( + uploads: List?, + user: User, + previouslyUploadedFileSize: Int, + totalUploadSize: Int, + client: OwnCloudClient, + accountName: String + ): Result { + for ((index, upload) in uploads?.withIndex()!!) { ensureActive() if (preferences.isGlobalUploadPaused) { @@ -261,12 +355,12 @@ class FileUploadWorker( notificationManager.notifyPaused( intents.openUploadListIntent(null) ) - return@withContext Result.success() + return Result.success() } if (canExitEarly()) { notificationManager.showConnectionErrorNotification() - return@withContext Result.failure() + return Result.failure() } setWorkerState(user) @@ -298,9 +392,8 @@ class FileUploadWorker( sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) } - return@withContext Result.success() + return Result.success() } - private fun sendUploadFinishEvent( totalUploadSize: Int, currentUploadIndex: Int, From 5011c0fb9625cc61f0c3167eff121d10bbf25a57 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Sun, 11 Jan 2026 22:24:14 +0000 Subject: [PATCH 010/125] Fixed notification manager updates jumping around and improved thread safety Signed-off-by: Raphael Vieira --- .../client/jobs/upload/FileUploadWorker.kt | 185 +++++++----------- 1 file changed, 71 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index d7522364f445..20912bce53f0 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -53,6 +53,9 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import kotlin.random.Random @Suppress("LongParameterList", "TooGenericExceptionCaught") @@ -81,7 +84,7 @@ class FileUploadWorker( const val TOTAL_UPLOAD_SIZE = "total_upload_size" const val SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION = "show_same_file_already_exists_notification" - var currentUploadFileOperation: UploadFileOperation? = null + private val activeUploadFileOperations = ConcurrentHashMap() private const val UPLOADS_ADDED_MESSAGE = "UPLOADS_ADDED" private const val UPLOAD_START_MESSAGE = "UPLOAD_START" @@ -109,20 +112,18 @@ class FileUploadWorker( fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE fun cancelCurrentUpload(remotePath: String, accountName: String, onCompleted: () -> Unit) { - currentUploadFileOperation?.let { + activeUploadFileOperations.values.forEach { if (it.remotePath == remotePath && it.user.accountName == accountName) { it.cancel(ResultCode.USER_CANCELLED) - onCompleted() } } + onCompleted() } fun isUploading(remotePath: String?, accountName: String?): Boolean { - currentUploadFileOperation?.let { - return it.remotePath == remotePath && it.user.accountName == accountName - } - - return false + return activeUploadFileOperations.values.any { + it.remotePath == remotePath && it.user.accountName == accountName + } } fun getUploadAction(action: String): Int = when (action) { @@ -133,7 +134,9 @@ class FileUploadWorker( } } - private var lastPercent = 0 + private val lastPercents = ConcurrentHashMap() + private val lastUpdateTimes = ConcurrentHashMap() + private val notificationId = Random.nextInt() private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId) private val intents = FileUploaderIntents(context) @@ -208,7 +211,7 @@ class FileUploadWorker( Log_OC.e(TAG, "FileUploadWorker stopped") setIdleWorkerState() - currentUploadFileOperation?.cancel(null) + activeUploadFileOperations.values.forEach { it.cancel(null) } notificationManager.dismissNotification() } @@ -217,7 +220,8 @@ class FileUploadWorker( } private fun setIdleWorkerState() { - WorkerStateObserver.send(WorkerState.FileUploadCompleted(currentUploadFileOperation?.file)) + val lastOp = activeUploadFileOperations.values.lastOrNull() + WorkerStateObserver.send(WorkerState.FileUploadCompleted(lastOp?.file)) } @Suppress("ReturnCount", "LongMethod", "DEPRECATION") @@ -260,8 +264,6 @@ class FileUploadWorker( val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) return@withContext parallelUpload(uploads, user, previouslyUploadedFileSize, totalUploadSize, client, accountName) - - // return@withContext sequentialUpload(uploads, user, previouslyUploadedFileSize, totalUploadSize, client, accountName) } @@ -273,23 +275,28 @@ class FileUploadWorker( client: OwnCloudClient, accountName: String ): Result { + if (uploads.isNullOrEmpty()) { + return Result.success() + } + val semaphore = Semaphore(5) // Limit to 5 parallel uploads - var quotaExceeded = false + val quotaExceeded = AtomicBoolean(false) + val completedCount = AtomicInteger(0) coroutineScope { - for ((index, upload) in uploads?.withIndex()!!) { - if (quotaExceeded) break + for (upload in uploads) { + if (quotaExceeded.get()) break ensureActive() launch { - semaphore.withPermit { - if (quotaExceeded || isStopped) return@launch + if (preferences.isGlobalUploadPaused) { + Log_OC.d(TAG, "Upload is paused, skip uploading files!") + notificationManager.notifyPaused(intents.openUploadListIntent(null)) + return@launch + } - if (preferences.isGlobalUploadPaused) { - Log_OC.d(TAG, "Upload is paused, skip uploading files!") - notificationManager.notifyPaused(intents.openUploadListIntent(null)) - return@launch - } + semaphore.withPermit { + if (quotaExceeded.get() || isStopped) return@launch if (canExitEarly()) { notificationManager.showConnectionErrorNotification() @@ -298,102 +305,49 @@ class FileUploadWorker( setWorkerState(user) val operation = createUploadFileOperation(upload, user) - - // NOTE: currentUploadFileOperation is a companion property. - // Parallelizing will cause race conditions here. - // You should ideally move this to a thread-safe collection if needed. - currentUploadFileOperation = operation - - val currentIndex = (index + 1) - val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) - - // Synchronize notification updates if they aren't thread-safe - synchronized(notificationManager) { - notificationManager.prepareForStart( - operation, - startIntent = intents.openUploadListIntent(operation), - currentUploadIndex = currentUploadIndex, - totalUploadSize = totalUploadSize - ) - } + activeUploadFileOperations[operation.originalStoragePath] = operation + + try { + val currentUploadIndex = previouslyUploadedFileSize + completedCount.incrementAndGet() + + // Synchronize notification updates + synchronized(notificationManager) { + notificationManager.prepareForStart( + operation, + startIntent = intents.openUploadListIntent(operation), + currentUploadIndex = currentUploadIndex, + totalUploadSize = totalUploadSize + ) + } val result = upload(operation, user, client) val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) uploadsStorageManager.updateStatus(entity, result.isSuccess) - if (result.code == ResultCode.QUOTA_EXCEEDED) { - Log_OC.w(TAG, "Quota exceeded, stopping uploads") - notificationManager.showQuotaExceedNotification(operation) - quotaExceeded = true - this@coroutineScope.cancel("Quota exceeded") - return@launch - } + if (result.code == ResultCode.QUOTA_EXCEEDED) { + Log_OC.w(TAG, "Quota exceeded, stopping uploads") + notificationManager.showQuotaExceedNotification(operation) + quotaExceeded.set(true) + this@coroutineScope.cancel("Quota exceeded") + return@launch + } sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) - } + + } finally { + activeUploadFileOperations.remove(operation.originalStoragePath) + lastPercents.remove(operation.originalStoragePath) + lastUpdateTimes.remove(operation.originalStoragePath) + } + } } } } - return if (quotaExceeded) Result.failure() else Result.success() + return if (quotaExceeded.get()) Result.failure() else Result.success() } - private suspend fun CoroutineScope.sequentialUpload( - uploads: List?, - user: User, - previouslyUploadedFileSize: Int, - totalUploadSize: Int, - client: OwnCloudClient, - accountName: String - ): Result { - for ((index, upload) in uploads?.withIndex()!!) { - ensureActive() - - if (preferences.isGlobalUploadPaused) { - Log_OC.d(TAG, "Upload is paused, skip uploading files!") - notificationManager.notifyPaused( - intents.openUploadListIntent(null) - ) - return Result.success() - } - - if (canExitEarly()) { - notificationManager.showConnectionErrorNotification() - return Result.failure() - } - - setWorkerState(user) - val operation = createUploadFileOperation(upload, user) - currentUploadFileOperation = operation - - val currentIndex = (index + 1) - val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) - notificationManager.prepareForStart( - operation, - startIntent = intents.openUploadListIntent(operation), - currentUploadIndex = currentUploadIndex, - totalUploadSize = totalUploadSize - ) - - val result = withContext(Dispatchers.IO) { - upload(operation, user, client) - } - val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) - uploadsStorageManager.updateStatus(entity, result.isSuccess) - currentUploadFileOperation = null - - if (result.code == ResultCode.QUOTA_EXCEEDED) { - Log_OC.w(TAG, "Quota exceeded, stopping uploads") - notificationManager.showQuotaExceedNotification(operation) - break - } - - sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) - } - - return Result.success() - } private fun sendUploadFinishEvent( totalUploadSize: Int, currentUploadIndex: Int, @@ -503,20 +457,24 @@ class FileUploadWorker( totalToTransfer: Long, fileAbsoluteName: String ) { + val operation = activeUploadFileOperations[fileAbsoluteName] ?: return val percent = getPercent(totalTransferredSoFar, totalToTransfer) val currentTime = System.currentTimeMillis() + val lastPercent = lastPercents[fileAbsoluteName] ?: 0 + val lastUpdateTime = lastUpdateTimes[fileAbsoluteName] ?: 0L + if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) { - notificationManager.run { - val accountName = currentUploadFileOperation?.user?.accountName - val remotePath = currentUploadFileOperation?.remotePath + synchronized(notificationManager) { + val accountName = operation.user.accountName + val remotePath = operation.remotePath - updateUploadProgress(percent, currentUploadFileOperation) + notificationManager.updateUploadProgress(percent, operation) if (accountName != null && remotePath != null) { val key: String = FileUploadHelper.buildRemoteName(accountName, remotePath) val boundListener = FileUploadHelper.mBoundListeners[key] - val filename = currentUploadFileOperation?.fileName ?: "" + val filename = operation.fileName ?: "" boundListener?.onTransferProgress( progressRate, @@ -526,11 +484,10 @@ class FileUploadWorker( ) } - dismissOldErrorNotification(currentUploadFileOperation) + notificationManager.dismissOldErrorNotification(operation) } - lastUpdateTime = currentTime + lastUpdateTimes[fileAbsoluteName] = currentTime + lastPercents[fileAbsoluteName] = percent } - - lastPercent = percent } } From f1110827ce15dc9cb443946e0d1e090246923bc2 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Sun, 11 Jan 2026 23:49:35 +0000 Subject: [PATCH 011/125] fixed FileUploadHelper to make sure uploads are correctly reflecting progress Signed-off-by: Raphael Vieira --- .../client/jobs/upload/FileUploadHelper.kt | 19 ++++----- .../client/jobs/upload/FileUploadWorker.kt | 40 +++++++++++-------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 566d80415c08..9ca2d89a972a 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -18,7 +18,7 @@ import com.nextcloud.client.database.entity.toUploadEntity import com.nextcloud.client.device.BatteryStatus import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.currentUploadFileOperation +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.activeUploadFileOperations import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService import com.nextcloud.utils.extensions.getUploadIds @@ -360,17 +360,12 @@ class FileUploadHelper { @Suppress("ReturnCount") fun isUploadingNow(upload: OCUpload?): Boolean { - val currentUploadFileOperation = currentUploadFileOperation - if (currentUploadFileOperation == null || currentUploadFileOperation.user == null) return false - if (upload == null || upload.accountName != currentUploadFileOperation.user.accountName) return false - - return if (currentUploadFileOperation.oldFile != null) { - // For file conflicts check old file remote path - upload.remotePath == currentUploadFileOperation.remotePath || - upload.remotePath == currentUploadFileOperation.oldFile!! - .remotePath - } else { - upload.remotePath == currentUploadFileOperation.remotePath + upload ?: return false + + return activeUploadFileOperations.values.any { operation -> + operation.user?.accountName == upload.accountName && + (upload.remotePath == operation.remotePath || + upload.remotePath == operation.oldFile?.remotePath) } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 20912bce53f0..2023680d97d4 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -43,7 +43,6 @@ 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.theme.ViewThemeUtils -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope @@ -84,8 +83,7 @@ class FileUploadWorker( const val TOTAL_UPLOAD_SIZE = "total_upload_size" const val SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION = "show_same_file_already_exists_notification" - private val activeUploadFileOperations = ConcurrentHashMap() - + val activeUploadFileOperations = ConcurrentHashMap() private const val UPLOADS_ADDED_MESSAGE = "UPLOADS_ADDED" private const val UPLOAD_START_MESSAGE = "UPLOAD_START" private const val UPLOAD_FINISH_MESSAGE = "UPLOAD_FINISH" @@ -123,7 +121,7 @@ class FileUploadWorker( fun isUploading(remotePath: String?, accountName: String?): Boolean { return activeUploadFileOperations.values.any { it.remotePath == remotePath && it.user.accountName == accountName - } + } } fun getUploadAction(action: String): Int = when (action) { @@ -263,10 +261,16 @@ class FileUploadWorker( val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) - return@withContext parallelUpload(uploads, user, previouslyUploadedFileSize, totalUploadSize, client, accountName) + return@withContext parallelUpload( + uploads, + user, + previouslyUploadedFileSize, + totalUploadSize, + client, + accountName + ) } - private suspend fun parallelUpload( uploads: List?, user: User, @@ -279,9 +283,10 @@ class FileUploadWorker( return Result.success() } - val semaphore = Semaphore(5) // Limit to 5 parallel uploads + val semaphore = Semaphore(10) // Limit to 10 parallel uploads val quotaExceeded = AtomicBoolean(false) val completedCount = AtomicInteger(0) + val storageManager = FileDataStorageManager(user, context.contentResolver) coroutineScope { for (upload in uploads) { @@ -304,7 +309,7 @@ class FileUploadWorker( } setWorkerState(user) - val operation = createUploadFileOperation(upload, user) + val operation = createUploadFileOperation(upload, user, storageManager) activeUploadFileOperations[operation.originalStoragePath] = operation try { @@ -320,10 +325,10 @@ class FileUploadWorker( ) } - val result = upload(operation, user, client) + val result = upload(operation, user, client) - val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) - uploadsStorageManager.updateStatus(entity, result.isSuccess) + val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) + uploadsStorageManager.updateStatus(entity, result.isSuccess) if (result.code == ResultCode.QUOTA_EXCEEDED) { Log_OC.w(TAG, "Quota exceeded, stopping uploads") @@ -333,14 +338,13 @@ class FileUploadWorker( return@launch } - sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) - + sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) } finally { activeUploadFileOperations.remove(operation.originalStoragePath) lastPercents.remove(operation.originalStoragePath) lastUpdateTimes.remove(operation.originalStoragePath) } - } + } } } } @@ -383,7 +387,11 @@ class FileUploadWorker( return result } - private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation( + private fun createUploadFileOperation( + upload: OCUpload, + user: User, + storageManager: FileDataStorageManager + ): UploadFileOperation = UploadFileOperation( uploadsStorageManager, connectivityService, powerManagementService, @@ -396,7 +404,7 @@ class FileUploadWorker( upload.isUseWifiOnly, upload.isWhileChargingOnly, true, - FileDataStorageManager(user, context.contentResolver) + storageManager ).apply { addDataTransferProgressListener(this@FileUploadWorker) } From 978998823f3e69ebae4d0706e4e925b0007fcf41 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Mon, 12 Jan 2026 00:17:26 +0000 Subject: [PATCH 012/125] removed magic number Signed-off-by: Raphael Vieira --- .../com/nextcloud/client/jobs/upload/FileUploadWorker.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 2023680d97d4..958681fe839e 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -81,6 +81,12 @@ class FileUploadWorker( const val UPLOAD_IDS = "uploads_ids" const val CURRENT_BATCH_INDEX = "batch_index" const val TOTAL_UPLOAD_SIZE = "total_upload_size" + + /** + * The maximum number of concurrent parallel uploads + */ + const val MAX_CONCURRENT_UPLOADS = 10 + const val SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION = "show_same_file_already_exists_notification" val activeUploadFileOperations = ConcurrentHashMap() @@ -283,7 +289,7 @@ class FileUploadWorker( return Result.success() } - val semaphore = Semaphore(10) // Limit to 10 parallel uploads + val semaphore = Semaphore(MAX_CONCURRENT_UPLOADS) val quotaExceeded = AtomicBoolean(false) val completedCount = AtomicInteger(0) val storageManager = FileDataStorageManager(user, context.contentResolver) From c3ac6e16dfbd549e3de164eac5fb59eebc77fe01 Mon Sep 17 00:00:00 2001 From: Nextcloud bot Date: Mon, 12 Jan 2026 02:48:42 +0000 Subject: [PATCH 013/125] fix(l10n): Update translations from Transifex Signed-off-by: Nextcloud bot Signed-off-by: Raphael Vieira --- app/src/main/res/values-ru/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f0eef136ea99..ce0c7ffdd39e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -44,8 +44,12 @@ Показывает один виджет с главного экрана. Искать в %s \"Не в сети\" для остальных + Не удалось отправить сообщение + Не удалось загрузить сообщения чата Вы уверены, что хотите удалить эту задачу? Удалить задачу + Попробуйте отправить сообщение, чтобы начать беседу. + Здравствуйте! Чем я могу вам помочь сегодня? Произошла ошибка при создании задачи Задача успешно создана Произошла ошибка во время удаления задачи @@ -198,7 +202,9 @@ Не удалось запустить импорт. Пожалуйста, попробуйте снова Файл не найден Не удалось найти последнюю резервную копию! + Не удалось создать чат Удалить обсуждение + Не удалось создать чат. Не найдено ни одного обсуждения Беседы Скопировано From 57b974457354e1dc31486a0cd929a3b6b2f01a20 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Mon, 12 Jan 2026 07:03:53 +0100 Subject: [PATCH 014/125] update updateLibraryHash.sh script to use .kts Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- scripts/updateLibraryHash.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/updateLibraryHash.sh b/scripts/updateLibraryHash.sh index 7500b834f5e8..4caa531fc081 100755 --- a/scripts/updateLibraryHash.sh +++ b/scripts/updateLibraryHash.sh @@ -9,17 +9,17 @@ git checkout master git pull latestCommit=$(curl -s https://api.github.com/repos/nextcloud/android-library/commits/master | jq .sha | sed s'/\"//g') -currentCommit=$(grep "androidLibraryVersion" build.gradle | cut -f2 -d'"') +currentCommit=$(grep "androidLibraryVersion =" gradle/libs.versions.toml | cut -f2 -d'"') [[ $latestCommit == "$currentCommit" ]] && echo "Nothing to do. Commit is: $latestCommit" && exit # nothing to do git fetch git checkout -B update-library-"$(date +%F)" origin/master -sed -i s"#androidLibraryVersion\ =.*#androidLibraryVersion =\"$latestCommit\"#" build.gradle +sed -i s"#androidLibraryVersion\ =.*#androidLibraryVersion =\"$latestCommit\"#" gradle/libs.versions.toml ./gradlew --console=plain --dependency-verification lenient -q --write-verification-metadata sha256,pgp help -git add build.gradle +git add gradle/libs.versions.toml git add gradle/verification-metadata.xml git commit -s -m "Update library to $(date +%F)" From 1f23f2e62cb096ad008b8ccaaf0eeb6f6e3fc9dc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 03:00:44 +0000 Subject: [PATCH 015/125] chore(deps): update dependency fastlane to v2.230.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: Raphael Vieira --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ed8611d08318..0e43b8661fc9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,8 +8,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1201.0) - aws-sdk-core (3.241.2) + aws-partitions (1.1202.0) + aws-sdk-core (3.241.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,11 +17,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.119.0) - aws-sdk-core (~> 3, >= 3.241.0) + aws-sdk-kms (1.120.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.210.1) - aws-sdk-core (~> 3, >= 3.241.0) + aws-sdk-s3 (1.211.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) From 1979215d523d00c8e22d8227b6d2c03cd56386a7 Mon Sep 17 00:00:00 2001 From: Tobias Kaminsky Date: Mon, 12 Jan 2026 07:06:28 +0100 Subject: [PATCH 016/125] Update library to 2026-01-12 Signed-off-by: Tobias Kaminsky Signed-off-by: Raphael Vieira --- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d386304e7c28..61ed6cbe4e5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidCommonLibraryVersion = "0.31.0" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" -androidLibraryVersion = "b711becb0fe4fa3b09a0ea83a498579310cc8e69" +androidLibraryVersion ="795e952c01d6cb37c8bf2efd86b2a716a2ea2985" androidPluginVersion = '8.13.2' androidsvgVersion = "1.4" androidxMediaVersion = "1.5.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 26b454512fca..b47fa0922c2c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -19931,6 +19931,14 @@ + + + + + + + + From 3d8914a1b94aeaeefb73cbc772d4a09d843fe72c Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Mon, 12 Jan 2026 07:12:59 +0100 Subject: [PATCH 017/125] update detectWrongSettings.sh Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- scripts/analysis/detectWrongSettings.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/analysis/detectWrongSettings.sh b/scripts/analysis/detectWrongSettings.sh index c8cfbf2ea52c..d9ae88c323f3 100755 --- a/scripts/analysis/detectWrongSettings.sh +++ b/scripts/analysis/detectWrongSettings.sh @@ -8,7 +8,7 @@ snapshotCount=$(./gradlew dependencies | grep SNAPSHOT -c) betaCount=$(grep "true" app/src/main/res/values/setup.xml -c) # Read androidLibraryVersion from TOML -libraryHash=$(grep 'androidLibraryVersion' gradle/libs.versions.toml \ +libraryHash=$(grep 'androidLibraryVersion =' gradle/libs.versions.toml \ | cut -d '=' -f2 \ | tr -d ' "' ) From 846894d512c0d31594bf85eb4c3fcf780788f7cd Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Wed, 3 Sep 2025 12:42:56 +0200 Subject: [PATCH 018/125] GetCapabilitiesRemoteOperation now returns OCCapability Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- .../java/com/owncloud/android/AbstractIT.java | 6 +---- .../datamodel/FileDataStorageManagerIT.java | 4 +--- .../operations/GetCapabilitiesOperation.java | 7 +++--- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 22 +++++++++++++++++++ 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java index 5ea27541062f..f7ab18fe76c1 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java @@ -208,11 +208,7 @@ protected void testOnlyOnServer(OwnCloudVersion version) throws AccountUtils.Acc protected OCCapability getCapability() throws AccountUtils.AccountNotFoundException { NextcloudClient client = OwnCloudClientFactory.createNextcloudClient(user, targetContext); - OCCapability ocCapability = (OCCapability) new GetCapabilitiesRemoteOperation() - .execute(client) - .getSingleData(); - - return ocCapability; + return new GetCapabilitiesRemoteOperation().execute(client).getResultData(); } @Before diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java index 34f80613a00a..f8b04338182d 100644 --- a/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java @@ -53,9 +53,7 @@ public void before() { assertEquals(0, sut.getAllFiles().size()); - capability = (OCCapability) new GetCapabilitiesRemoteOperation(null) - .execute(client) - .getSingleData(); + capability = new GetCapabilitiesRemoteOperation(null).execute(client).getResultData(); } @After diff --git a/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java b/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java index 9e86d161a6d0..067727c519a8 100644 --- a/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java @@ -36,12 +36,11 @@ protected RemoteOperationResult run(OwnCloudClient client) { currentCapability = storageManager.getCapability(storageManager.getUser().getAccountName()); } - RemoteOperationResult result = new GetCapabilitiesRemoteOperation(currentCapability).execute(client); + RemoteOperationResult result = new GetCapabilitiesRemoteOperation(currentCapability).execute(client); - if (result.isSuccess() - && result.getData() != null && result.getData().size() > 0) { + if (result.isSuccess() && result.getResultData() != null) { // Read data from the result - OCCapability capability = (OCCapability) result.getData().get(0); + OCCapability capability = result.getResultData(); // Save the capabilities into database storageManager.saveCapabilities(capability); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61ed6cbe4e5e..0d38589758a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidCommonLibraryVersion = "0.31.0" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" -androidLibraryVersion ="795e952c01d6cb37c8bf2efd86b2a716a2ea2985" +androidLibraryVersion = "498d2b9dc8fb1e626f6a7c5cbe548556633adf1d" androidPluginVersion = '8.13.2' androidsvgVersion = "1.4" androidxMediaVersion = "1.5.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index b47fa0922c2c..3f57975eb445 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -19643,6 +19643,17 @@ + + + + + + + + @@ -19762,6 +19773,17 @@ + + + + + + + + From 185b6f38869c1513d425111c5201f4751fe02429 Mon Sep 17 00:00:00 2001 From: ZetaTom <70907959+ZetaTom@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:14:30 +0100 Subject: [PATCH 019/125] Remove logic to handle FAB visibility. Signed-off-by: ZetaTom <70907959+ZetaTom@users.noreply.github.com> Signed-off-by: Raphael Vieira --- .../owncloud/android/ui/fragment/FileDetailFragment.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java index 555286f2e6ad..49df63f254eb 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java @@ -857,12 +857,6 @@ public void showHideFragmentView(boolean isFragmentReplaced) { binding.tabLayout.setVisibility(isFragmentReplaced ? View.GONE : View.VISIBLE); binding.pager.setVisibility(isFragmentReplaced ? View.GONE : View.VISIBLE); binding.sharingFrameContainer.setVisibility(isFragmentReplaced ? View.VISIBLE : View.GONE); - FloatingActionButton mFabMain = requireActivity().findViewById(R.id.fab_main); - if (isFragmentReplaced) { - mFabMain.hide(); - } else { - mFabMain.show(); - } } /** From a28033737a410f1a8fa6e103447ba6a9943992dc Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 8 Jan 2026 15:31:45 +0100 Subject: [PATCH 020/125] fix(theming): Improve color theming and general layouting Resolves #16245 Signed-off-by: Andy Scherzinger Signed-off-by: Raphael Vieira --- .../android/ui/activity/EditorWebView.java | 3 + .../ui/activity/InternalTwoWaySyncActivity.kt | 5 +- .../ui/adapter/InternalTwoWaySyncAdapter.kt | 18 ++-- .../android/ui/adapter/LinkShareViewHolder.kt | 6 +- .../android/ui/adapter/ShareViewHolder.java | 1 + .../ui/fragment/FileDetailFragment.java | 1 + .../fragment/FileDetailSharingFragment.java | 10 ++- .../theme/FilesSpecificViewThemeUtils.kt | 22 +++++ .../main/res/layout/file_details_fragment.xml | 1 + .../layout/file_details_sharing_fragment.xml | 83 ++++++++++--------- .../layout/internal_two_way_sync_layout.xml | 16 ++-- .../internal_two_way_sync_view_holder.xml | 21 ++--- app/src/main/res/values-night/colors.xml | 2 +- app/src/main/res/values/colors.xml | 2 +- app/src/main/res/values/dims.xml | 1 + app/src/main/res/values/strings.xml | 2 +- 16 files changed, 120 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java index d216177436a0..cb03db76006b 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java @@ -25,6 +25,7 @@ import android.webkit.WebView; import com.google.android.material.snackbar.Snackbar; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; import com.nextcloud.client.account.User; import com.nextcloud.utils.extensions.IntentExtensionsKt; import com.owncloud.android.R; @@ -130,6 +131,8 @@ protected void bindView() { protected void postOnCreate() { super.postOnCreate(); + viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar2, ColorRole.PRIMARY); + getWebView().setWebChromeClient(new WebChromeClient() { final EditorWebView activity = EditorWebView.this; diff --git a/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt index 89d37b84da4d..f85127cd2ded 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt @@ -50,7 +50,8 @@ class InternalTwoWaySyncActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - internalTwoWaySyncAdapter = InternalTwoWaySyncAdapter(fileDataStorageManager, user.get(), this, this) + internalTwoWaySyncAdapter = + InternalTwoWaySyncAdapter(fileDataStorageManager, user.get(), this, this, viewThemeUtils) binding = InternalTwoWaySyncLayoutBinding.inflate(layoutInflater) setContentView(binding.root) @@ -163,6 +164,7 @@ class InternalTwoWaySyncActivity : handleDurationSelected(durations[position].first.inWholeMinutes) } } + viewThemeUtils.material.colorTextInputLayout(binding.twoWaySyncIntervalLayout) } private fun handleDurationSelected(duration: Long) { @@ -184,6 +186,7 @@ class InternalTwoWaySyncActivity : backgroundJobManager.cancelTwoWaySyncJob() } } + viewThemeUtils.material.colorMaterialSwitch(binding.twoWaySyncToggle) } private fun checkLayoutVisibilities(condition: Boolean) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncAdapter.kt index 376d32deb34b..6c2cbe368de8 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncAdapter.kt @@ -12,16 +12,19 @@ import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.User import com.owncloud.android.databinding.InternalTwoWaySyncViewHolderBinding import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.theme.ViewThemeUtils class InternalTwoWaySyncAdapter( private val dataStorageManager: FileDataStorageManager, private val user: User, val context: Context, - private val onUpdateListener: InternalTwoWaySyncAdapterOnUpdate + private val onUpdateListener: InternalTwoWaySyncAdapterOnUpdate, + private val viewThemeUtils: ViewThemeUtils ) : RecyclerView.Adapter() { interface InternalTwoWaySyncAdapterOnUpdate { @@ -30,14 +33,11 @@ class InternalTwoWaySyncAdapter( var folders: List = dataStorageManager.getInternalTwoWaySyncFolders(user) - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InternalTwoWaySyncViewHolder = - InternalTwoWaySyncViewHolder( - InternalTwoWaySyncViewHolderBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InternalTwoWaySyncViewHolder { + val binding = InternalTwoWaySyncViewHolderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + viewThemeUtils.platform.colorImageView(binding.folderIcon, ColorRole.PRIMARY) + return InternalTwoWaySyncViewHolder(binding) + } override fun getItemCount(): Int = folders.size diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/LinkShareViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/LinkShareViewHolder.kt index dd9a01832ccb..3ebe6b08d380 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/LinkShareViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/LinkShareViewHolder.kt @@ -93,11 +93,9 @@ internal class LinkShareViewHolder(itemView: View) : RecyclerView.ViewHolder(ite } val label = publicShare.label - if (label.isNullOrEmpty()) { - return + if (!label.isNullOrEmpty()) { + binding.name.text = context.getString(R.string.share_link_with_label, label) } - - binding.name.text = context.getString(R.string.share_link_with_label, label) } private fun setSubline(binding: FileDetailsShareLinkShareItemBinding?, context: Context?, publicShare: OCShare) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ShareViewHolder.java b/app/src/main/java/com/owncloud/android/ui/adapter/ShareViewHolder.java index 26159013d544..f57a1701c6c4 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ShareViewHolder.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ShareViewHolder.java @@ -135,6 +135,7 @@ private void setPermissionName(String permissionName) { if (!TextUtils.isEmpty(permissionName)) { binding.permissionName.setText(permissionName); binding.permissionName.setVisibility(View.VISIBLE); + viewThemeUtils.androidx.colorPrimaryTextViewElement(binding.permissionName); } else { binding.permissionName.setVisibility(View.GONE); } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java index 49df63f254eb..595ccacce1e1 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java @@ -276,6 +276,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { if (getFile() != null && user != null) { viewThemeUtils.platform.themeHorizontalProgressBar(binding.progressBar); + viewThemeUtils.platform.themeCheckbox(binding.folderSyncButton); progressListener = new DownloadProgressListener(binding.progressBar); binding.cancelBtn.setOnClickListener(this); binding.favorite.setOnClickListener(this); diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailSharingFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailSharingFragment.java index 969b29c8d349..23b3e553f1e6 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailSharingFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailSharingFragment.java @@ -34,6 +34,7 @@ import android.view.animation.AnimationUtils; import android.widget.LinearLayout; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; import com.nextcloud.client.account.User; import com.nextcloud.client.account.UserAccountManager; import com.nextcloud.client.database.entity.FileEntity; @@ -292,8 +293,12 @@ private void setupView() { (SearchManager) fileActivity.getSystemService(Context.SEARCH_SERVICE), binding.searchView, fileActivity.getComponentName()); - viewThemeUtils.androidx.themeToolbarSearchView(binding.searchView); + viewThemeUtils.material.themeSearchCardView(binding.searchCardWrapper); + viewThemeUtils.files.themeContentSearchView(binding.searchView); + viewThemeUtils.platform.colorImageView(binding.searchViewIcon, ColorRole.ON_SURFACE_VARIANT); + viewThemeUtils.platform.colorImageView(binding.pickContactEmailBtn, ColorRole.ON_SURFACE_VARIANT); + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.sharesListInternalShowAll); viewThemeUtils.material.colorMaterialTextButton(binding.sharesListInternalShowAll); binding.sharesListInternalShowAll.setOnClickListener(view -> { internalShareeListAdapter.toggleShowAll(); @@ -301,6 +306,9 @@ private void setupView() { binding.sharesListInternalShowAll.setText(textRes); }); + viewThemeUtils.material.colorMaterialButtonPrimaryOutlined(binding.createLink); + + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.sharesListExternalShowAll); viewThemeUtils.material.colorMaterialTextButton(binding.sharesListExternalShowAll); binding.sharesListExternalShowAll.setOnClickListener(view -> { externalShareeListAdapter.toggleShowAll(); diff --git a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt index b2acffbafbf8..2ad091baa356 100644 --- a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt @@ -16,11 +16,15 @@ import android.preference.PreferenceCategory import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan +import android.view.View import android.widget.ImageView +import android.widget.LinearLayout import androidx.annotation.DrawableRes import androidx.annotation.Px import androidx.annotation.StringRes import androidx.appcompat.app.ActionBar +import androidx.appcompat.widget.AppCompatAutoCompleteTextView +import androidx.appcompat.widget.SearchView import androidx.core.content.res.ResourcesCompat import com.google.android.material.card.MaterialCardView import com.nextcloud.android.common.ui.color.ColorUtil @@ -36,6 +40,7 @@ import me.zhanghai.android.fastscroll.FastScrollerBuilder import me.zhanghai.android.fastscroll.PopupStyles import javax.inject.Inject +@Suppress("TooManyFunctions") class FilesSpecificViewThemeUtils @Inject constructor( schemes: MaterialSchemes, private val colorUtil: ColorUtil, @@ -256,6 +261,23 @@ class FilesSpecificViewThemeUtils @Inject constructor( supportActionBar.setHomeAsUpIndicator(tinted) } + fun themeContentSearchView(searchView: SearchView) { + withScheme(searchView) { scheme -> + // hacky as no default way is provided + val editText = searchView + .findViewById(androidx.appcompat.R.id.search_src_text) as AppCompatAutoCompleteTextView + val searchPlate = searchView.findViewById(androidx.appcompat.R.id.search_plate) as LinearLayout + val closeButton = searchView.findViewById(androidx.appcompat.R.id.search_close_btn) as ImageView + val searchButton = searchView.findViewById(androidx.appcompat.R.id.search_button) as ImageView + editText.setHintTextColor(scheme.onSurfaceVariant) + editText.highlightColor = scheme.inverseOnSurface + editText.setTextColor(scheme.onSurface) + closeButton.setColorFilter(scheme.onSurface) + searchButton.setColorFilter(scheme.onSurface) + searchPlate.setBackgroundColor(scheme.surfaceContainerHigh) + } + } + companion object { private val TAG = FilesSpecificViewThemeUtils::class.simpleName diff --git a/app/src/main/res/layout/file_details_fragment.xml b/app/src/main/res/layout/file_details_fragment.xml index 8ed20b18a048..aac508c36f68 100644 --- a/app/src/main/res/layout/file_details_fragment.xml +++ b/app/src/main/res/layout/file_details_fragment.xml @@ -198,6 +198,7 @@ diff --git a/app/src/main/res/layout/file_details_sharing_fragment.xml b/app/src/main/res/layout/file_details_sharing_fragment.xml index 310c49197c6e..02401065bf07 100644 --- a/app/src/main/res/layout/file_details_sharing_fragment.xml +++ b/app/src/main/res/layout/file_details_sharing_fragment.xml @@ -83,45 +83,53 @@ - - - + android:layout_marginHorizontal="@dimen/standard_margin" + android:layout_marginTop="@dimen/standard_half_margin" + app:cardBackgroundColor="@color/grey_600" + app:cardCornerRadius="28dp" + app:strokeWidth="0dp"> - - - + android:orientation="horizontal"> + + + + + + - + + + + android:background="@color/list_divider_background" /> @@ -202,5 +210,4 @@ - diff --git a/app/src/main/res/layout/internal_two_way_sync_layout.xml b/app/src/main/res/layout/internal_two_way_sync_layout.xml index 19d3e5074183..57bbce6139d8 100644 --- a/app/src/main/res/layout/internal_two_way_sync_layout.xml +++ b/app/src/main/res/layout/internal_two_way_sync_layout.xml @@ -22,21 +22,21 @@ + android:minHeight="@dimen/minimum_size_for_touchable_area" + android:text="@string/prefs_two_way_sync_title" + android:textSize="@dimen/txt_size_16sp" /> diff --git a/app/src/main/res/layout/internal_two_way_sync_view_holder.xml b/app/src/main/res/layout/internal_two_way_sync_view_holder.xml index ba949f304bfc..26942304fdc7 100644 --- a/app/src/main/res/layout/internal_two_way_sync_view_holder.xml +++ b/app/src/main/res/layout/internal_two_way_sync_view_holder.xml @@ -2,36 +2,36 @@ +--> + android:orientation="horizontal"> diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index db1e1d218038..bc3d5e523b54 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -11,7 +11,7 @@ #000000 #ff6F6F6F #A5A5A5 - #222222 + #49454F #222222 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 36d7459ecdaf..c48ded2bf87d 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -20,7 +20,7 @@ @color/secondary_text_color #ffffff #ff888888 - #eeeeee + #CAC4D0 #FFFFFF #DDDDDD #EEEEEE diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 3192d5eb772f..d021ac9ac1c1 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -142,6 +142,7 @@ 10dp 60dp 48dp + 12dp 24dp 400dp 24dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eac2c70202b3..7259956bf44b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,7 +139,7 @@ Dark Follow system Theme - Enable two way sync + Two way sync Interval From a5a908873a9a439fbaba30d02395f22b818ca2b4 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 1 Jan 2026 16:31:04 +0100 Subject: [PATCH 021/125] style(searchbar): Migrate to M3 search bar style Signed-off-by: Andy Scherzinger Signed-off-by: Raphael Vieira --- .../android/ui/activity/ToolbarActivity.java | 8 +- app/src/main/res/layout/toolbar_standard.xml | 187 +++++++++--------- 2 files changed, 100 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java index 75bbfa7ff180..63489451c7db 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java @@ -64,7 +64,8 @@ public abstract class ToolbarActivity extends BaseActivity implements Injectable private AppBarLayout mAppBar; private RelativeLayout mDefaultToolbar; private MaterialToolbar mToolbar; - private MaterialCardView mHomeSearchToolbar; + private MaterialCardView mHomeSearchContainer; + private LinearLayout mHomeSearchToolbar; private ImageView mPreviewImage; private FrameLayout mPreviewImageContainer; private LinearLayout mInfoBox; @@ -88,6 +89,7 @@ private void setupToolbar(boolean isHomeSearchToolbarShow, boolean showSortListB mAppBar = findViewById(R.id.appbar); mDefaultToolbar = findViewById(R.id.default_toolbar); mHomeSearchToolbar = findViewById(R.id.home_toolbar); + mHomeSearchContainer = findViewById(R.id.home_search_container); mMenuButton = findViewById(R.id.menu_button); mSearchText = findViewById(R.id.search_text); mSwitchAccountButton = findViewById(R.id.switch_account_button); @@ -113,7 +115,7 @@ private void setupToolbar(boolean isHomeSearchToolbarShow, boolean showSortListB viewThemeUtils.platform.themeStatusBar(this); viewThemeUtils.material.colorMaterialTextButton(mSwitchAccountButton); - viewThemeUtils.material.themeSearchCardView(mHomeSearchToolbar); + viewThemeUtils.material.themeSearchCardView(mHomeSearchContainer); viewThemeUtils.material.colorMaterialButtonContent(mMenuButton, ColorRole.ON_SURFACE); viewThemeUtils.material.colorMaterialButtonContent(mNotificationButton, ColorRole.ON_SURFACE); viewThemeUtils.platform.colorTextView(mSearchText, ColorRole.ON_SURFACE_VARIANT); @@ -286,7 +288,7 @@ private void showHomeSearchToolbar(boolean isShow) { R.animator.appbar_elevation_off)); mDefaultToolbar.setVisibility(View.GONE); mHomeSearchToolbar.setVisibility(View.VISIBLE); - viewThemeUtils.material.themeSearchCardView(mHomeSearchToolbar); + viewThemeUtils.material.themeSearchCardView(mHomeSearchContainer); viewThemeUtils.material.themeSearchBarText(mSearchText); } else { mAppBar.setStateListAnimator(AnimatorInflater.loadStateListAnimator(mAppBar.getContext(), diff --git a/app/src/main/res/layout/toolbar_standard.xml b/app/src/main/res/layout/toolbar_standard.xml index b44970084204..8c6ee74c84c4 100644 --- a/app/src/main/res/layout/toolbar_standard.xml +++ b/app/src/main/res/layout/toolbar_standard.xml @@ -2,9 +2,9 @@ @@ -56,11 +56,11 @@ + tools:visibility="visible"> @@ -145,91 +145,94 @@ android:lines="1" android:textSize="16sp" android:layout_width="wrap_content" - android:layout_height="48dp"/> + android:layout_height="@dimen/minimum_size_for_touchable_area"/> - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + From 12321b688adea8f933a85e12e5473eea34fa3609 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Thu, 1 Jan 2026 12:59:22 +0100 Subject: [PATCH 022/125] Removed left-over usages of gplay flavor for screenshot tests Flavor was changed in #15062, bf46332a, from gplay to generic, but not all instances were adjusted. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .github/workflows/screenShotTest.yml | 2 +- CONTRIBUTING.md | 2 +- scripts/runAllScreenshotCombinations | 4 ++-- scripts/updateScreenshots.sh | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/screenShotTest.yml b/.github/workflows/screenShotTest.yml index ec78dcb5928f..540f3663abed 100644 --- a/.github/workflows/screenShotTest.yml +++ b/.github/workflows/screenShotTest.yml @@ -75,7 +75,7 @@ jobs: echo "org.gradle.configureondemand=true" >> $HOME/.gradle/gradle.properties echo "kapt.incremental.apt=true" >> $HOME/.gradle/gradle.properties - - name: Build gplay + - name: Build generic flavor run: ./gradlew assembleGenericDebug - name: Delete old comments diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5fab39edd484..c755179b0c93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -232,7 +232,7 @@ Source code of app: #### UI tests We use [shot](https://github.com/Karumi/Shot) for taking screenshots and compare them -- check screenshots: ```./gradlew gplayDebugExecuteScreenshotTests ``` +- check screenshots: ```./gradlew genericDebugExecuteScreenshotTests ``` - update/generate new screenshots: ```scripts/updateScreenshots.sh ``` - in this script are samples how to only execute a given class/test - this will fire up docker & emulator to ensure that screenshots look the same diff --git a/scripts/runAllScreenshotCombinations b/scripts/runAllScreenshotCombinations index db1ce06cc507..6e208d20eed0 100755 --- a/scripts/runAllScreenshotCombinations +++ b/scripts/runAllScreenshotCombinations @@ -28,7 +28,7 @@ do echo -n "Run $color on $darkMode mode" if [[ $1 = "noCI" ]]; then - ./gradlew --console plain gplayDebugExecuteScreenshotTests \ + ./gradlew --console plain genericDebugExecuteScreenshotTests \ $record \ -Pscreenshot=true \ -Pandroid.testInstrumentationRunnerArguments.annotation=com.owncloud.android.utils.ScreenshotTest \ @@ -41,7 +41,7 @@ do echo fi else - ./gradlew --console plain gplayDebugExecuteScreenshotTests \ + ./gradlew --console plain genericDebugExecuteScreenshotTests \ $record \ -Pandroid.testInstrumentationRunnerArguments.annotation=com.owncloud.android.utils.ScreenshotTest \ -Pandroid.testInstrumentationRunnerArguments.COLOR="$color" \ diff --git a/scripts/updateScreenshots.sh b/scripts/updateScreenshots.sh index d64ad43aa513..ec60df32738b 100755 --- a/scripts/updateScreenshots.sh +++ b/scripts/updateScreenshots.sh @@ -77,17 +77,17 @@ adb shell "echo $IP server >> /system/etc/hosts" sed -i s'#false#true#'g app/src/main/res/values/setup.xml ## update/create all screenshots -#./gradlew gplayDebugExecuteScreenshotTests -Precord \ +#./gradlew genericDebugExecuteScreenshotTests -Precord \ #-Pandroid.testInstrumentationRunnerArguments.annotation=com.owncloud.android.utils.ScreenshotTest ## update screenshots in a class -#./gradlew gplayDebugExecuteScreenshotTests \ +#./gradlew genericDebugExecuteScreenshotTests \ #-Precord \ #-Pandroid.testInstrumentationRunnerArguments.class=\ #com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragmentTest ## update single screenshot within a class -#./gradlew gplayDebugExecuteScreenshotTests \ +#./gradlew genericDebugExecuteScreenshotTests \ #-Precord \ #-Pandroid.testInstrumentationRunnerArguments.class=\ #com.nextcloud.client.FileDisplayActivityIT#showShares @@ -97,7 +97,7 @@ retryCount=0 until [ $resultCode -eq 0 ] || [ $retryCount -gt 2 ] do # test all screenshots - ./gradlew gplayDebugExecuteScreenshotTests \ + ./gradlew genericDebugExecuteScreenshotTests \ -Pandroid.testInstrumentationRunnerArguments.annotation=com.owncloud.android.utils.ScreenshotTest resultCode=$? From 6b35ae43009a0ca8ba6833685d0244ee665c33fc Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Thu, 1 Jan 2026 15:09:03 +0100 Subject: [PATCH 023/125] Added documentation for local execution of screenshot tests. Since 002f61a2, the 'shot' dependency is excluded from the normal build, so it requires appropriate preconditions to be executed. Otherwise, the gradle task `genericDebugExecuteScreenshotTests` will not even be available. You can either ensure the appropriate setup manually, or better use the utility scripts. To facilitate the latter, some documentation was added as well. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- CONTRIBUTING.md | 7 ++++-- scripts/androidScreenshotTest | 41 +++++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c755179b0c93..157e4b18eddf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -231,8 +231,11 @@ Source code of app: #### UI tests -We use [shot](https://github.com/Karumi/Shot) for taking screenshots and compare them -- check screenshots: ```./gradlew genericDebugExecuteScreenshotTests ``` +We use [shot](https://github.com/Karumi/Shot) for taking screenshots and compare them. +To exclude the shot dependency from normal builds, the dependency needs to be activated via an environment variable `SHOT_TEST`. +For convenience, this and other prerequisites are encapsulated in utility scripts, so it is advised to use them +- check screenshots: ```scripts/androidScreenshotTest ``` + - check the script for a detailed documentation of the parameters - update/generate new screenshots: ```scripts/updateScreenshots.sh ``` - in this script are samples how to only execute a given class/test - this will fire up docker & emulator to ensure that screenshots look the same diff --git a/scripts/androidScreenshotTest b/scripts/androidScreenshotTest index 1366405ffc19..febf3dba7726 100755 --- a/scripts/androidScreenshotTest +++ b/scripts/androidScreenshotTest @@ -1,18 +1,45 @@ #!/bin/bash # -# SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2020-2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2026 Philipp Hasper # SPDX-FileCopyrightText: 2020-2024 Tobias Kaminsky # SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only # +# Run instrumentation screenshot tests (record or compare) for a given test class/method. +# +# Usage: +# ./scripts/androidScreenshotTest [method] [darkMode: dark|light|all] [color] +# +# Arguments: +# record "true" to record/update reference screenshots (-Precord), "false" to compare. +# class name the first matching .java or .kt file under app/src/androidTest/java is passed to the instrumentation runner +# method name optional test method name to run (appends #method to class). If not given, the entire class's tests run +# darkMode optional: dark | light | all (if "all", the script runs scripts/runAllScreenshotCombinations). +# color optional value passed as COLOR to the instrumentation runner, for testing different color themes +# +# Behavior notes: +# - Temporarily sets is_beta to true in app/src/main/res/values/setup.xml and restores it afterwards. +# - Searches for or launches an emulator AVD named "uiComparison" and sets ANDROID_SERIAL for the run. +# - Requires adb and emulator on PATH and the repo's ./gradlew wrapper. +# - Caution: If interrupted the setup.xml change may remain; revert with: +# git checkout -- app/src/main/res/values/setup.xml +# +# Examples: +# ./scripts/androidScreenshotTest false MyScreenshotTest +# ./scripts/androidScreenshotTest true MyScreenshotTest testCaptureAll all +# ./scripts/androidScreenshotTest false MyScreenshotTest "" dark red +# END_DOCUMENTATION set -e if [ $# -lt 2 ]; then - echo "1: record: true/false -2: class name -3: method name -4: darkMode: dark/light / \"all\" to run all screenshot combinations -5: color" - + # Print the documentation from the file header, up until END_DOCUMENTATION, excluding specific lines + sed -n '2,/^#[[:space:]]*END_DOCUMENTATION/ { + /^#[[:space:]]*SPDX-FileCopyrightText/ d + /^#[[:space:]]*SPDX-License-Identifier/ d + /^#[[:space:]]*END_DOCUMENTATION/ d + s/^#// + p + }' "$0" | sed '/^$/d' exit fi From f05c688291c8c29ce0372708cfab6ba75ef302a5 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Thu, 1 Jan 2026 16:26:51 +0100 Subject: [PATCH 024/125] androidScreenshotTest now sets up the uiComparison AVD, if not present The AVD setup was only done automatically when executing updateScreenshots.sh. The lines were copied from there, but long-term, the exact AVD configuration as well as emulator parameters should rather be stored somewhere shared. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- scripts/androidScreenshotTest | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/androidScreenshotTest b/scripts/androidScreenshotTest index febf3dba7726..c1d3035dfe8c 100755 --- a/scripts/androidScreenshotTest +++ b/scripts/androidScreenshotTest @@ -92,6 +92,20 @@ while read line ; do done < <(adb devices | cut -f1) if [ "$emulatorIsRunning" == false ] ; then + if [ -z "$(command -v emulator || true)" ]; then + echo "emulator not found in PATH; typically located in Android/sdk/emulator" >&2 + exit 1 + fi + if [ -z "$(command -v avdmanager || true)" ]; then + echo "avdmanager not found in PATH; typically located in Android/sdk/cmdline-tools/latest/bin" >&2 + exit 1 + fi + # If emulator of expected name doesn't exist, create it + # TODO - this was copied from updateScreenshots.sh - move it to a common helper script + if [[ $(emulator -list-avds | grep uiComparison -c) -eq 0 ]]; then + (sleep 5; echo "no") | avdmanager create avd -n uiComparison -c 100M -k "system-images;android-28;google_apis;x86" --abi "google_apis/x86" + fi + "$(command -v emulator)" -writable-system -avd uiComparison -no-snapshot -gpu swiftshader_indirect -no-audio -skin 500x833 & sleep 20 fi From 8e4d33ab9809bc6acfbaaaa896a0cdc7f61bcb03 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 1 Jan 2026 18:49:10 +0100 Subject: [PATCH 025/125] fix(avatar): Fix avatar sizing for non-user avatars Signed-off-by: Andy Scherzinger Signed-off-by: Raphael Vieira --- .../main/java/com/owncloud/android/ui/AvatarGroupLayout.kt | 7 ++----- .../android/utils/theme/FilesSpecificViewThemeUtils.kt | 5 +++-- app/src/main/res/drawable/ic_talk.xml | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt index ab22b2c314fd..b57e29a083b6 100644 --- a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt +++ b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt @@ -1,11 +1,7 @@ /* * Nextcloud Android client application * - * @author Andy Scherzinger - * @author Stefan Niedermann - * Copyright (C) 2021 Andy Scherzinger - * Copyright (C) 2021 Stefan Niedermann - * + * SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.owncloud.android.ui @@ -76,6 +72,7 @@ class AvatarGroupLayout @JvmOverloads constructor( val avatar = ImageView(context).apply { layoutParams = avatarLayoutParams setPadding(avatarBorderSize, avatarBorderSize, avatarBorderSize, avatarBorderSize) + scaleType = ImageView.ScaleType.CENTER_INSIDE background = borderDrawable } diff --git a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt index 2ad091baa356..4fb2d277c0cd 100644 --- a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt @@ -1,8 +1,8 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2022-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.owncloud.android.utils.theme @@ -70,6 +70,7 @@ class FilesSpecificViewThemeUtils @Inject constructor( null ) avatar.cropToPadding = true + avatar.scaleType = ImageView.ScaleType.CENTER_INSIDE avatar.setPadding(padding, padding, padding, padding) } @@ -83,7 +84,7 @@ class FilesSpecificViewThemeUtils @Inject constructor( androidViewThemeUtils.colorImageViewBackgroundAndIcon(avatar) } ShareType.ROOM -> { - createAvatarBase(R.drawable.first_run_talk, AvatarPadding.LARGE) + createAvatarBase(R.drawable.ic_talk, AvatarPadding.LARGE) androidViewThemeUtils.colorImageViewBackgroundAndIcon(avatar) } ShareType.CIRCLE -> { diff --git a/app/src/main/res/drawable/ic_talk.xml b/app/src/main/res/drawable/ic_talk.xml index 8957c64bd25a..e55ac5d9a2ac 100644 --- a/app/src/main/res/drawable/ic_talk.xml +++ b/app/src/main/res/drawable/ic_talk.xml @@ -6,8 +6,8 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only --> From 19a4632af4cd2b3c58015975459057bcef68c5cb Mon Sep 17 00:00:00 2001 From: Nextcloud bot Date: Tue, 13 Jan 2026 02:55:48 +0000 Subject: [PATCH 026/125] fix(l10n): Update translations from Transifex Signed-off-by: Nextcloud bot Signed-off-by: Raphael Vieira --- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cae35b760225..9f38f57cd7bc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -201,6 +201,7 @@ 대화를 찾을 수 없습니다. 아직 대화가 존재하지 않음 대화 + 복사됨 이 파일이나 폴더를 복사할 수 없음 폴더를 자기 자신의 하위 폴더로 복사할 수 없음 파일이 대상 폴더에 이미 존재함 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ce0c7ffdd39e..6dfd569a8b05 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -44,6 +44,7 @@ Показывает один виджет с главного экрана. Искать в %s \"Не в сети\" для остальных + Выходные данные, показанные здесь, сгенерированы с помощью искусственного интеллекта. Обязательно всегда перепроверяйте. Не удалось отправить сообщение Не удалось загрузить сообщения чата Вы уверены, что хотите удалить эту задачу? @@ -60,6 +61,7 @@ Помощник Ввод Вывод + Думаю... Связанный аккаунт не найден! Доступ запрещен: %1$s Учётная запись ещё не создана на этом устройстве @@ -93,11 +95,14 @@ Отменить вход Пожалуйста, введите действующий адрес сервера. При обработке вашего запроса на вход возникла проблема. Пожалуйста, повторите попытку позже. + Ни один браузер не доступен для открытия этой ссылки. Пожалуйста, завершите процесс входа в вашем браузере оставлен в исходном каталоге, т.к. файл только для чтения + Батарея разряжена, загрузка может занять больше времени Только через безлимит или Wi-Fi /Автозагрузка Эта папка уже включена в синхронизацию родительской папки, что может привести к дублированию загрузок + Ожидание Wi-Fi для начала загрузки Настроить Создать новую настройку пользовательского каталога Задать пользовательский каталог @@ -607,6 +612,7 @@ Не удалось выполнить задание. Показывать уведомления для взаимодействия с результатами фоновых операций Фоновые задания + Обнаруживать локальные изменения файлов Показывает выполнение получения Загрузки Показывает состояние и результаты синхронизации файлов @@ -630,6 +636,7 @@ Отсутствует подключение к Интернет Даже без подключения к Интернету вы можете упорядочивать папки и создавать файлы. Как только вы вернетесь в сеть, ваши действия будут автоматически синхронизированы. Вы не в сети, но работа продолжается + Файл ещё не существует. Пожалуйста, сначала загрузите файл. Не удалось создать %s. На сервере уже существует файл с таким же именем. Не удалось создать %s. На сервере уже существует папка с таким же имененм. Операция в автономном режиме не может быть завершена. %s @@ -645,6 +652,7 @@ Дополнительное меню Введите код Введите ваш код + Пароль будет запрашиваться каждый раз при открытии приложения или его повторном открытии через 5 секунд. Коды не совпадают Введите ваш код ещё раз Удалите ваш код @@ -673,6 +681,7 @@ Переименовать новую версию Действие в случае, если файл уже существует Добавить аккаунт + Разрешить приложению доступ и управление всеми файлами на вашем устройстве Синхронизировать календарь и контакты Ни F-Droid, ни Google Play не установлены Установите DAVx⁵ (ранее известный как DAVdroid) (v1.3.0+) для текущего аккаунта @@ -908,6 +917,9 @@ Внутреннее хранилище Фильмы Музыка + Для авто-загрузки требуется разрешение на доступ к хранилищу. + Для загрузки файлов требуется разрешение на доступ к хранилищу. + Не спрашивать Носитель только для чтения Изображения Устанавливаемая на свой сервер платформа для повышения продуктивности, которая позволяет держать всё под контролем.\n\nFeatures:\n* Простой, современный интерфейс, соответствующий тематике вашего сервера\n* Загружайте файлы на свой сервер Nextcloud\n* Делитесь ими с другими\n* Синхронизируйте избранные файлы и папки\n* Поиск по всем папкам на вашем сервере\n* Автоматическая загрузка фотографий и видео, снятых на вашем устройстве\n* Будьте в курсе событий благодаря уведомлениям\n* Поддержка нескольких учётных записей\n* Безопасный доступ к вашим данным с помощью отпечатка пальца или PIN-кода\n* Интеграция с DAVx⁵ (ранее известен как DAVdroid) для простой настройки синхронизации календаря и контактов\n\nПожалуйста, сообщайте обо всех проблемах на https://github.com/nextcloud/android/issues и обсуждайте это приложение на https://help.nextcloud.com/c/clients/android\n\nНовичок в Nextcloud? Nextcloud — это приватный сервер синхронизации и обмена файлами и коммуникационный сервер. Это свободное программное обеспечение, и вы можете разместить его самостоятельно или заплатить компании, чтобы это сделали за вас. Таким образом, вы управляете своими фотографиями, календарём и контактными данными, документами и всем остальным.\n\nОзнакомьтесь с Nextcloud по адресу https://nextcloud.com From ebeec51a30730ea24d851589924decf83ec524c3 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Wed, 2 Jul 2025 10:40:57 +0200 Subject: [PATCH 027/125] Declarative UI Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- .../97.json | 1298 +++++++++++++++++ .../client/database/NextcloudDatabase.kt | 3 +- .../database/entity/CapabilityEntity.kt | 4 +- .../nextcloud/ui/ClientIntegrationScreen.kt | 170 +++ .../ui/composeActivity/ComposeActivity.kt | 39 +- .../ui/composeActivity/ComposeDestination.kt | 24 +- .../ui/fileactions/ClientIntegration.kt | 231 +++ .../nextcloud/ui/fileactions/FileAction.kt | 2 + .../ui/fileactions/FileActionsBottomSheet.kt | 31 +- .../ui/fileactions/FileActionsViewModel.kt | 1 + .../datamodel/FileDataStorageManager.java | 4 + .../com/owncloud/android/db/ProviderMeta.java | 3 +- .../android/ui/activity/DrawerActivity.java | 14 +- .../ui/fragment/OCFileListFragment.java | 7 +- .../layout/file_actions_bottom_sheet_item.xml | 2 - app/src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 82 +- 18 files changed, 1868 insertions(+), 51 deletions(-) create mode 100644 app/schemas/com.nextcloud.client.database.NextcloudDatabase/97.json create mode 100644 app/src/main/java/com/nextcloud/ui/ClientIntegrationScreen.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/97.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/97.json new file mode 100644 index 000000000000..27f506701672 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/97.json @@ -0,0 +1,1298 @@ +{ + "formatVersion": 1, + "database": { + "version": 97, + "identityHash": "1c5a77152bf79ee80f9e6eb2677d75a7", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `forbidden_filename_characters` INTEGER, `forbidden_filenames` INTEGER, `forbidden_filename_extensions` INTEGER, `forbidden_filename_basenames` INTEGER, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER, `client_integration_json` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientIntegrationJson", + "columnName": "client_integration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "assistant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT, `type` TEXT, `status` TEXT, `userId` TEXT, `appId` TEXT, `input` TEXT, `output` TEXT, `completionExpectedAt` INTEGER, `progress` INTEGER, `lastUpdated` INTEGER, `scheduledAt` INTEGER, `endedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT" + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "TEXT" + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "completionExpectedAt", + "columnName": "completionExpectedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1c5a77152bf79ee80f9e6eb2677d75a7')" + ] + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt index a5bafa686668..de3d4911a253 100644 --- a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt +++ b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt @@ -91,7 +91,8 @@ import com.owncloud.android.db.ProviderMeta AutoMigration(from = 92, to = 93, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), AutoMigration(from = 93, to = 94, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), AutoMigration(from = 94, to = 95, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), - AutoMigration(from = 95, to = 96) + AutoMigration(from = 95, to = 96), + AutoMigration(from = 96, to = 97, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class) ], exportSchema = true ) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt index 56f33b18f0bd..a2712c7b04f7 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt @@ -146,5 +146,7 @@ data class CapabilityEntity( @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES) val isWCFEnabled: Int?, @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION) - val hasValidSubscription: Int? + val hasValidSubscription: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_CLIENT_INTEGRATION_JSON) + val clientIntegrationJson: String? ) diff --git a/app/src/main/java/com/nextcloud/ui/ClientIntegrationScreen.kt b/app/src/main/java/com/nextcloud/ui/ClientIntegrationScreen.kt new file mode 100644 index 000000000000..f7cc6b585097 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/ClientIntegrationScreen.kt @@ -0,0 +1,170 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2025 Tobias Kaminsky + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui + +import android.app.Activity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.android.lib.resources.clientintegration.ClientIntegrationUI +import com.nextcloud.android.lib.resources.clientintegration.Element +import com.nextcloud.android.lib.resources.clientintegration.Layout +import com.nextcloud.android.lib.resources.clientintegration.LayoutButton +import com.nextcloud.android.lib.resources.clientintegration.LayoutOrientation +import com.nextcloud.android.lib.resources.clientintegration.LayoutRow +import com.nextcloud.android.lib.resources.clientintegration.LayoutText +import com.nextcloud.android.lib.resources.clientintegration.LayoutURL +import com.nextcloud.utils.extensions.getActivity +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.utils.DisplayUtils + +@Composable +fun ClientIntegrationScreen(clientIntegrationUI: ClientIntegrationUI, baseUrl: String) { + val activity = LocalContext.current.getActivity() + val layoutRows = clientIntegrationUI.root?.rows ?: listOf() + + Scaffold(topBar = { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + IconButton(onClick = { activity?.finish() }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close" + ) + } + } + }, modifier = Modifier.fillMaxSize()) { + when (clientIntegrationUI.root?.orientation) { + LayoutOrientation.VERTICAL -> { + LazyColumn(modifier = Modifier.padding(it)) { + items(layoutRows) { row -> + LazyRow { + items(row.children) { element -> + DisplayElement(element, baseUrl, activity) + } + } + } + } + } + else -> { + LazyRow(modifier = Modifier.padding(it)) { + items(layoutRows) { row -> + LazyColumn { + items(row.children) { element -> + DisplayElement(element, baseUrl, activity) + } + } + } + } + } + } + } +} + +@Composable +private fun DisplayElement(element: Element, baseUrl: String, activity: Activity?) { + when (element) { + is LayoutButton -> Button(onClick = { }) { + Text(element.label) + } + + is LayoutURL -> TextButton({ + openLink(activity, baseUrl, element.url) + }) { Text(element.text) } + + is LayoutText -> Text(element.text) + } +} + +private fun openLink(activity: Activity?, baseUrl: String, relativeUrl: String) { + activity?.let { + DisplayUtils.startLinkIntent(activity, baseUrl + relativeUrl) + } +} + +@Composable +@Preview +private fun ClientIntegrationScreenPreviewVertical() { + val clientIntegrationUI = ClientIntegrationUI( + OCCapability.CLIENT_INTEGRATION_VERSION, + Layout( + LayoutOrientation.VERTICAL, + mutableListOf( + LayoutRow( + listOf(LayoutButton("Click", "Primary"), LayoutText("123")) + ), + LayoutRow( + listOf(LayoutButton("Click2", "Primary")) + ), + LayoutRow( + listOf(LayoutURL("Analytics report created", "https://nextcloud.com")) + ) + ) + ) + ) + + ClientIntegrationScreen( + clientIntegrationUI, + "http://nextcloud.local" + ) +} + +@Composable +@Preview +private fun ClientIntegrationScreenPreviewHorizontal() { + val clientIntegrationUI = ClientIntegrationUI( + OCCapability.CLIENT_INTEGRATION_VERSION, + Layout( + LayoutOrientation.HORIZONTAL, + mutableListOf( + LayoutRow( + listOf(LayoutButton("Click", "Primary"), LayoutText("123")) + ), + LayoutRow( + listOf(LayoutButton("Click2", "Primary")) + ), + LayoutRow( + listOf(LayoutURL("Analytics report created", "https://nextcloud.com")) + ) + ) + ) + ) + + ClientIntegrationScreen(clientIntegrationUI, "http://nextcloud.local") +} + +@Composable +@Preview +private fun ClientIntegrationScreenPreviewEmpty() { + val clientIntegrationUI = ClientIntegrationUI( + OCCapability.CLIENT_INTEGRATION_VERSION, + Layout( + LayoutOrientation.HORIZONTAL, + emptyList() + ) + ) + + ClientIntegrationScreen(clientIntegrationUI, "http://nextcloud.local") +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt index 27b295bac9c3..1428ab363b76 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt @@ -9,6 +9,7 @@ package com.nextcloud.ui.composeActivity import android.os.Bundle import android.view.MenuItem +import android.view.View import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -25,6 +26,8 @@ import com.nextcloud.client.assistant.repository.local.AssistantLocalRepositoryI import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositoryImpl import com.nextcloud.client.database.NextcloudDatabase import com.nextcloud.common.NextcloudClient +import com.nextcloud.ui.ClientIntegrationScreen +import com.nextcloud.utils.extensions.getParcelableArgument import com.owncloud.android.R import com.owncloud.android.databinding.ActivityComposeBinding import com.owncloud.android.ui.activity.DrawerActivity @@ -35,7 +38,6 @@ class ComposeActivity : DrawerActivity() { companion object { const val DESTINATION = "DESTINATION" - const val TITLE = "TITLE" } override fun onCreate(savedInstanceState: Bundle?) { @@ -43,25 +45,37 @@ class ComposeActivity : DrawerActivity() { binding = ActivityComposeBinding.inflate(layoutInflater) setContentView(binding.root) - val destinationId = intent.getIntExtra(DESTINATION, -1) - val titleId = intent.getIntExtra(TITLE, R.string.empty) - - setupDrawer() + val destination = + intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) ?: throw IllegalArgumentException( + "destination is not exists" + ) - setupToolbarShowOnlyMenuButtonAndTitle(getString(titleId)) { - openDrawer() - } + setupActivityUIFor(destination) binding.composeView.setContent { MaterialTheme( colorScheme = viewThemeUtils.getColorScheme(this), content = { - Content(ComposeDestination.fromId(destinationId)) + Content(destination) } ) } } + private fun setupActivityUIFor(destination: ComposeDestination) { + if (destination is ComposeDestination.AssistantScreen) { + setupDrawer() + setupToolbarShowOnlyMenuButtonAndTitle(destination.title) { + openDrawer() + } + } else { + setSupportActionBar(null) + findViewById(R.id.appbar)?.let { + it.visibility = View.GONE + } + } + } + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { toggleDrawer() @@ -104,6 +118,13 @@ class ComposeActivity : DrawerActivity() { capability = capabilities ) } + + is ComposeDestination.ClientIntegrationScreen -> { + binding.bottomNavigation.visibility = View.GONE + val integrationScreen = (currentScreen as ComposeDestination.ClientIntegrationScreen) + ClientIntegrationScreen(integrationScreen.data, nextcloudClient?.baseUri.toString()) + } + else -> Unit } } diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt index 050c8e5a2848..6564d61cd515 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt @@ -7,13 +7,25 @@ */ package com.nextcloud.ui.composeActivity -sealed class ComposeDestination(val id: Int) { - data class AssistantScreen(val sessionId: Long?) : ComposeDestination(0) +import android.content.Context +import android.os.Parcelable +import com.nextcloud.android.lib.resources.clientintegration.ClientIntegrationUI +import com.owncloud.android.R +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class ComposeDestination(val id: Int) : Parcelable { + @Parcelize + data class AssistantScreen(val title: String, val sessionId: Long?) : ComposeDestination(0) + + @Parcelize + data class ClientIntegrationScreen(val title: String, val data: ClientIntegrationUI) : ComposeDestination(1) companion object { - fun fromId(id: Int): ComposeDestination = when (id) { - 0 -> AssistantScreen(null) - else -> throw IllegalArgumentException("Unknown destination: $id") - } + /** + * Creates a assistant screen without selected chat + */ + fun getAssistantScreen(context: Context): AssistantScreen = + AssistantScreen(context.getString(R.string.assistant_screen_top_bar_title), null) } } diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt b/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt new file mode 100644 index 000000000000..0561a66b5178 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt @@ -0,0 +1,231 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileactions + +import android.content.Context +import android.content.Intent +import android.graphics.Canvas +import android.graphics.drawable.PictureDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import com.nextcloud.android.lib.resources.clientintegration.ClientIntegrationUI +import com.nextcloud.android.lib.resources.clientintegration.Element +import com.nextcloud.android.lib.resources.clientintegration.ElementTypeAdapter +import com.nextcloud.android.lib.resources.clientintegration.Endpoint +import com.nextcloud.android.lib.resources.clientintegration.TooltipResponse +import com.nextcloud.client.account.User +import com.nextcloud.common.JSONRequestBody +import com.nextcloud.operations.GetMethod +import com.nextcloud.operations.PostMethod +import com.nextcloud.ui.composeActivity.ComposeActivity +import com.nextcloud.ui.composeActivity.ComposeDestination +import com.nextcloud.utils.GlideHelper +import com.owncloud.android.R +import com.owncloud.android.databinding.FileActionsBottomSheetBinding +import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.ocs.ServerResponse +import com.owncloud.android.lib.resources.status.Method +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.RequestBody +import org.apache.commons.httpclient.HttpStatus +import java.io.IOException + +class ClientIntegration( + private var sheet: FileActionsBottomSheet, + private var user: User, + private var context: Context +) { + + fun inflateClientIntegrationActionView( + endpoint: Endpoint, + layoutInflater: LayoutInflater, + binding: FileActionsBottomSheetBinding, + viewModel: FileActionsViewModel, + viewThemeUtils: ViewThemeUtils + ): View { + val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false) + .apply { + root.setOnClickListener { + if (viewModel.uiState.value is FileActionsViewModel.UiState.LoadedForSingleFile) { + val singleFile = (viewModel.uiState.value as FileActionsViewModel.UiState.LoadedForSingleFile) + + val fileId = singleFile.titleFile?.localId.toString() + val filePath = singleFile.titleFile?.remotePath.toString() + + requestClientIntegration(endpoint, fileId, filePath) + } else { + requestClientIntegration(endpoint, "", "") + } + } + text.text = endpoint.name + + if (endpoint.icon != null) { + sheet.lifecycleScope.launch(Dispatchers.IO) { + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(user.toOwnCloudAccount(), context) + + val drawable = + GlideHelper.getDrawable(context, client, client.baseUri.toString() + endpoint.icon) + ?.mutate() + + val px = DisplayUtils.convertDpToPixel( + context.resources.getDimension(R.dimen.iconized_single_line_item_icon_size), + context + ) + val returnedBitmap = + createBitmap(drawable?.intrinsicWidth ?: px, drawable?.intrinsicHeight ?: px) + + val canvas = Canvas(returnedBitmap) + canvas.drawPicture((drawable as PictureDrawable).picture) + + val d = returnedBitmap.toDrawable(context.resources) + + val tintedDrawable = viewThemeUtils.platform.tintDrawable( + context, + d + ) + + withContext(Dispatchers.Main) { + icon.setImageDrawable(tintedDrawable) + } + } + } else { + val tintedDrawable = viewThemeUtils.platform.tintDrawable( + context, + AppCompatResources.getDrawable(context, R.drawable.ic_activity)!! + ) + + icon.setImageDrawable(tintedDrawable) + } + } + return itemBinding.root + } + + private fun requestClientIntegration(endpoint: Endpoint, fileId: String, filePath: String) { + sheet.lifecycleScope.launch(Dispatchers.IO) { + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getNextcloudClientFor(user.toOwnCloudAccount(), context) + + // construct url + var url = (client.baseUri.toString() + endpoint.url).toUri() + .buildUpon() + .appendQueryParameter("format", "json") + .build() + .toString() + + // Always replace known placeholder in url + url = url.replace("{filePath}", filePath, false) + url = url.replace("{fileId}", fileId, false) + + val method = when (endpoint.method) { + Method.POST -> { + val requestBody = if (endpoint.params?.isNotEmpty() == true) { + val jsonRequestBody = JSONRequestBody() + endpoint.params!!.forEach { + when (it.value) { + "{filePath}" -> jsonRequestBody.put(it.key, filePath) + "{fileId}" -> jsonRequestBody.put(it.key, fileId) + } + } + + jsonRequestBody.get() + } else { + RequestBody.EMPTY + } + + PostMethod(url, true, requestBody) + } + + else -> GetMethod(url, true) + } + + val result = try { + client.execute(method) + } catch (_: IOException) { + showMessage(context.resources.getString(R.string.failed_to_start_action)) + } + val response = method.getResponseBodyAsString() + + try { + val output = parseClientIntegrationResult(response) + if (output.root != null && output.root?.rows != null) { + startClientIntegration(endpoint, output) + } else { + val tooltipResponse = parseTooltipResult(response) + showMessage(tooltipResponse.tooltip) + } + } catch (_: JsonSyntaxException) { + if (result == HttpStatus.SC_OK) { + showMessage(context.resources.getString(R.string.action_triggered)) + } else { + showMessage(context.resources.getString(R.string.failed_to_start_action)) + } + } + sheet.dismiss() + } + } + + private suspend fun showMessage(message: String) = withContext(Dispatchers.Main) { + DisplayUtils.showSnackMessage(sheet.requireActivity(), message) + } + + private fun parseTooltipResult(response: String?): TooltipResponse { + val element: JsonElement = JsonParser.parseString(response) + return Gson() + .fromJson(element, object : TypeToken>() {}) + .ocs + .data + } + + private fun startClientIntegration(endpoint: Endpoint, data: ClientIntegrationUI) { + sheet.lifecycleScope.launch(Dispatchers.IO) { + val integrationScreen = ComposeDestination.ClientIntegrationScreen(endpoint.name, data) + + val bundle = Bundle().apply { + putParcelable(ComposeActivity.DESTINATION, integrationScreen) + } + + val composeActivity = Intent(context, ComposeActivity::class.java).apply { + putExtras(bundle) + } + + context.startActivity(composeActivity) + sheet.dismiss() + } + } + + private fun parseClientIntegrationResult(response: String?): ClientIntegrationUI { + val gson = + GsonBuilder() + .registerTypeHierarchyAdapter(Element::class.java, ElementTypeAdapter()) + .create() + + val element: JsonElement = JsonParser.parseString(response) + return gson + .fromJson(element, object : TypeToken>() {}) + .ocs + .data + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt index 6e265a2a7cd8..43f1edc09109 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt @@ -64,6 +64,8 @@ enum class FileAction( // Retry for offline operation RETRY(R.id.action_retry, R.string.retry, R.drawable.ic_retry); + constructor(id: Int, title: Int) : this(id, title, null) + companion object { /** * All file actions, in the order they should be displayed diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt index 7a67b8345b1c..62467f7d9b0f 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt @@ -29,6 +29,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.android.lib.resources.clientintegration.Endpoint import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.client.di.Injectable import com.nextcloud.client.di.ViewModelFactory @@ -77,6 +78,10 @@ class FileActionsBottomSheet : private val thumbnailAsyncTasks = mutableListOf() + private var endpoints: List? = mutableListOf() + + private lateinit var clientIntegration: ClientIntegration + fun interface ResultListener { fun onResult(@IdRes actionId: Int) } @@ -93,12 +98,16 @@ class FileActionsBottomSheet : viewModel.load(requireArguments(), componentsGetter) + endpoints = arguments?.getParcelableArrayList(FileActionsViewModel.ARG_ENDPOINTS) + val bottomSheetDialog = dialog as BottomSheetDialog bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED bottomSheetDialog.behavior.skipCollapsed = true viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE) + clientIntegration = ClientIntegration(this, currentUserProvider.user, requireContext()) + return binding.root } @@ -199,6 +208,20 @@ class FileActionsBottomSheet : val view = inflateActionView(action) binding.fileActionsList.addView(view) } + + // add client integration + if (endpoints != null) { + for (val e in endpoints) { + val ui = clientIntegration.inflateClientIntegrationActionView( + e, + layoutInflater, + binding, + viewModel, + viewThemeUtils + ) + binding.fileActionsList.addView(ui) + } + } } } @@ -322,7 +345,7 @@ class FileActionsBottomSheet : isOverflow: Boolean, @IdRes additionalToHide: List? = null - ): FileActionsBottomSheet = newInstance(1, listOf(file), isOverflow, additionalToHide, true) + ): FileActionsBottomSheet = newInstance(1, listOf(file), isOverflow, additionalToHide, true, emptyList()) @JvmStatic @JvmOverloads @@ -332,13 +355,15 @@ class FileActionsBottomSheet : isOverflow: Boolean, @IdRes additionalToHide: List? = null, - inSingleFileFragment: Boolean = false + inSingleFileFragment: Boolean = false, + endpoints: List ): FileActionsBottomSheet = FileActionsBottomSheet().apply { val argsBundle = bundleOf( FileActionsViewModel.ARG_ALL_FILES_COUNT to numberOfAllFiles, FileActionsViewModel.ARG_FILES to ArrayList(files), FileActionsViewModel.ARG_IS_OVERFLOW to isOverflow, - FileActionsViewModel.ARG_IN_SINGLE_FILE_FRAGMENT to inSingleFileFragment + FileActionsViewModel.ARG_IN_SINGLE_FILE_FRAGMENT to inSingleFileFragment, + FileActionsViewModel.ARG_ENDPOINTS to endpoints ) additionalToHide?.let { argsBundle.putIntArray(FileActionsViewModel.ARG_ADDITIONAL_FILTER, additionalToHide.toIntArray()) diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsViewModel.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsViewModel.kt index f42015b99518..62b310b010c2 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsViewModel.kt @@ -140,6 +140,7 @@ class FileActionsViewModel @Inject constructor( const val ARG_IS_OVERFLOW = "OVERFLOW" const val ARG_ADDITIONAL_FILTER = "ADDITIONAL_FILTER" const val ARG_IN_SINGLE_FILE_FRAGMENT = "IN_SINGLE_FILE_FRAGMENT" + const val ARG_ENDPOINTS = "ENDPOINTS" private val TAG = FileActionsViewModel::class.simpleName!! } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 0b0bba6c6812..497776f6babf 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2318,6 +2318,8 @@ private ContentValues createContentValues(String accountName, OCCapability capab contentValues.put(ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION, capability.getHasValidSubscription().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_CLIENT_INTEGRATION_JSON, capability.getClientIntegrationJson()); + return contentValues; } @@ -2503,6 +2505,8 @@ private OCCapability createCapabilityInstance(Cursor cursor) { capability.setDefaultPermissions(getInt(cursor, ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS)); capability.setHasValidSubscription(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION)); + + capability.setClientIntegrationJson(getString(cursor, ProviderTableMeta.CAPABILITIES_CLIENT_INTEGRATION_JSON)); } return capability; diff --git a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java index 46905d69231d..6168709a8b08 100644 --- a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java +++ b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java @@ -23,7 +23,7 @@ */ public class ProviderMeta { public static final String DB_NAME = "filelist"; - public static final int DB_VERSION = 96; + public static final int DB_VERSION = 97; private ProviderMeta() { // No instance @@ -292,6 +292,7 @@ static public class ProviderTableMeta implements BaseColumns { public static final String CAPABILITIES_NOTES_FOLDER_PATH = "notes_folder_path"; public static final String CAPABILITIES_DEFAULT_PERMISSIONS = "default_permissions"; public static final String CAPABILITIES_HAS_VALID_SUBSCRIPTION = "has_valid_subscription"; + public static final String CAPABILITIES_CLIENT_INTEGRATION_JSON = "client_integration_json"; //Columns of Uploads table public static final String UPLOADS_LOCAL_PATH = "local_path"; diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index bd992a780fe5..29849c71f0c5 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -434,7 +434,7 @@ private void showTopBanner(ConstraintLayout banner) { moreView.setOnClickListener(v -> LinkHelper.INSTANCE.openAppStore("Nextcloud", true, this)); assistantView.setOnClickListener(v -> { DrawerActivity.menuItemId = Menu.NONE; - startComposeActivity(new ComposeDestination.AssistantScreen(null), R.string.assistant_screen_top_bar_title); + startAssistantScreen(); }); if (getCapabilities() != null && getCapabilities().getAssistant().isTrue()) { assistantView.setVisibility(View.VISIBLE); @@ -586,7 +586,7 @@ private void onNavigationItemClicked(final MenuItem menuItem) { startRecentlyModifiedSearch(menuItem); } else if (itemId == R.id.nav_assistant) { resetOnlyPersonalAndOnDevice(); - startComposeActivity(new ComposeDestination.AssistantScreen(null), R.string.assistant_screen_top_bar_title); + startAssistantScreen(); } else if (itemId == R.id.nav_groupfolders) { resetOnlyPersonalAndOnDevice(); Intent intent = new Intent(getApplicationContext(), FileDisplayActivity.class); @@ -624,7 +624,7 @@ private void handleBottomNavigationViewClicks() { } else if (menuItemId == R.id.nav_favorites) { openFavoritesTab(); } else if (menuItemId == R.id.nav_assistant && !(this instanceof ComposeActivity)) { - startComposeActivity(new ComposeDestination.AssistantScreen(null), R.string.assistant_screen_top_bar_title); + startAssistantScreen(); } else if (menuItemId == R.id.nav_gallery) { openMediaTab(menuItem.getItemId()); } @@ -651,10 +651,12 @@ private void resetFileDepthAndConfigureMenuItem() { } } - private void startComposeActivity(ComposeDestination destination, int titleId) { + private void startAssistantScreen() { + final var destination = ComposeDestination.Companion.getAssistantScreen(this); Intent composeActivity = new Intent(getApplicationContext(), ComposeActivity.class); - composeActivity.putExtra(ComposeActivity.DESTINATION, destination.getId()); - composeActivity.putExtra(ComposeActivity.TITLE, titleId); + final Bundle bundle = new Bundle(); + bundle.putParcelable(ComposeActivity.DESTINATION, destination); + composeActivity.putExtras(bundle); startActivity(composeActivity); } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 981a234510b0..ae81d56b0427 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -38,6 +38,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; +import com.nextcloud.android.lib.resources.clientintegration.Endpoint; import com.nextcloud.android.lib.resources.files.ToggleFileLockRemoteOperation; import com.nextcloud.client.account.User; import com.nextcloud.client.device.DeviceInfo; @@ -79,6 +80,7 @@ import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation; import com.owncloud.android.lib.resources.status.E2EVersion; import com.owncloud.android.lib.resources.status.OCCapability; +import com.owncloud.android.lib.resources.status.Type; import com.owncloud.android.ui.activity.DrawerActivity; import com.owncloud.android.ui.activity.FileActivity; import com.owncloud.android.ui.activity.FileDisplayActivity; @@ -643,8 +645,11 @@ public void onOverflowIconClicked(OCFile file, View view) { public void openActionsMenu(final int filesCount, final Set checkedFiles, final boolean isOverflow) { throttler.run("overflowClick", () -> { final var actionsToHide = FileAction.Companion.getFileListActionsToHide(checkedFiles); + + List endpoints = getCapabilities().getClientIntegrationEndpoints(Type.CONTEXT_MENU, checkedFiles.iterator().next().getMimeType()); + final var childFragmentManager = getChildFragmentManager(); - final var actionBottomSheet = FileActionsBottomSheet.newInstance(filesCount, checkedFiles, isOverflow, actionsToHide) + final var actionBottomSheet = FileActionsBottomSheet.newInstance(filesCount, checkedFiles, isOverflow, actionsToHide, endpoints) .setResultListener(childFragmentManager, this, (id) -> onFileActionChosen(id, checkedFiles)); if (FragmentExtensionsKt.isDialogFragmentReady(this)) { diff --git a/app/src/main/res/layout/file_actions_bottom_sheet_item.xml b/app/src/main/res/layout/file_actions_bottom_sheet_item.xml index 77b245fee53b..b2a7a30738ee 100644 --- a/app/src/main/res/layout/file_actions_bottom_sheet_item.xml +++ b/app/src/main/res/layout/file_actions_bottom_sheet_item.xml @@ -7,7 +7,6 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only --> Failed to create conflict dialog Cannot open file chooser + Failed to start action! + Action triggered diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d38589758a6..0e9374986770 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidCommonLibraryVersion = "0.31.0" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" -androidLibraryVersion = "498d2b9dc8fb1e626f6a7c5cbe548556633adf1d" +androidLibraryVersion = "f4feed395eb081face0490447c07ce4ecd293baa" androidPluginVersion = '8.13.2' androidsvgVersion = "1.4" androidxMediaVersion = "1.5.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 3f57975eb445..4430400e843d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -19547,6 +19547,14 @@ + + + + + + + + @@ -19555,6 +19563,14 @@ + + + + + + + + @@ -19595,6 +19611,14 @@ + + + + + + + + @@ -19643,17 +19667,14 @@ - - - - - - - - + + + + + + + + @@ -19774,15 +19795,12 @@ - - - - - - + + + + + + @@ -20177,6 +20195,14 @@ + + + + + + + + @@ -20406,6 +20432,22 @@ + + + + + + + + + + + + + + + + From 6e13f8a3cf2ca0c12df8110011fc9ce176d73a69 Mon Sep 17 00:00:00 2001 From: nextcloud-android-bot Date: Tue, 13 Jan 2026 02:40:23 +0000 Subject: [PATCH 028/125] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'.githu?= =?UTF-8?q?b/workflows/'=20with=20remote=20'config/workflows/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nextcloud-android-bot Signed-off-by: Raphael Vieira --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/qa.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b4ec67f99f8b..722e834664b7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -43,7 +43,7 @@ jobs: with: swap-size-gb: 10 - name: Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: languages: ${{ matrix.language }} - name: Set up JDK 17 @@ -57,4 +57,4 @@ jobs: echo "org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" > "$HOME/.gradle/gradle.properties" ./gradlew --no-daemon assembleDebug - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index ab1e890188f8..ef51624feed4 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -48,8 +48,8 @@ jobs: mkdir -p "$HOME/.gradle" echo "org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" > "$HOME/.gradle/gradle.properties" echo "org.gradle.caching=true; org.gradle.parallel=true; org.gradle.configureondemand=true; kapt.incremental.apt=true" >> "$HOME/.gradle/gradle.properties" - sed -i "/qa/,/\}/ s/versionCode .*/versionCode = ${{github.event.number}} /" "app/build.gradle.kts" - sed -i "/qa/,/\}/ s/versionName .*/versionName = \"${{github.event.number}}\"/" "app/build.gradle.kts" + sed -i "/qa/,/\}/ s/versionCode .*/versionCode ${{github.event.number}} /" "app/build.gradle" + sed -i "/qa/,/\}/ s/versionName .*/versionName \"${{github.event.number}}\"/" "app/build.gradle" ./gradlew assembleQaDebug $(find /usr/local/lib/android/sdk/build-tools/*/apksigner | sort | tail -n1) sign --ks-pass pass:"$KS_PASS" --key-pass pass:"$KEY_PASS" --ks-key-alias key0 --ks ".github/workflows/QA_keystore.jks" app/build/outputs/apk/qa/debug/*qa-debug*.apk .github/workflows/uploadArtifact.sh "$LOG_USERNAME" "$LOG_PASSWORD" "${{github.event.number}}" "${{github.event.number}}" "$GITHUB_TOKEN" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b9a8df982bc6..63e25ecd8b49 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -42,6 +42,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: sarif_file: results.sarif From 20e9e8086e03d71c76e0685f8c004b4cca495612 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 13 Jan 2026 07:34:38 +0100 Subject: [PATCH 029/125] revert qa.xml Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- .github/workflows/qa.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index ef51624feed4..ab1e890188f8 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -48,8 +48,8 @@ jobs: mkdir -p "$HOME/.gradle" echo "org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" > "$HOME/.gradle/gradle.properties" echo "org.gradle.caching=true; org.gradle.parallel=true; org.gradle.configureondemand=true; kapt.incremental.apt=true" >> "$HOME/.gradle/gradle.properties" - sed -i "/qa/,/\}/ s/versionCode .*/versionCode ${{github.event.number}} /" "app/build.gradle" - sed -i "/qa/,/\}/ s/versionName .*/versionName \"${{github.event.number}}\"/" "app/build.gradle" + sed -i "/qa/,/\}/ s/versionCode .*/versionCode = ${{github.event.number}} /" "app/build.gradle.kts" + sed -i "/qa/,/\}/ s/versionName .*/versionName = \"${{github.event.number}}\"/" "app/build.gradle.kts" ./gradlew assembleQaDebug $(find /usr/local/lib/android/sdk/build-tools/*/apksigner | sort | tail -n1) sign --ks-pass pass:"$KS_PASS" --key-pass pass:"$KEY_PASS" --ks-key-alias key0 --ks ".github/workflows/QA_keystore.jks" app/build/outputs/apk/qa/debug/*qa-debug*.apk .github/workflows/uploadArtifact.sh "$LOG_USERNAME" "$LOG_PASSWORD" "${{github.event.number}}" "${{github.event.number}}" "$GITHUB_TOKEN" From 5c06c6d100895b73f4005250822698e64a8ef6fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:19:47 +0000 Subject: [PATCH 030/125] chore(deps): update dependency fastlane to v2.230.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: Raphael Vieira --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0e43b8661fc9..4c8b429d29bb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,7 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1202.0) + aws-partitions (1.1203.0) aws-sdk-core (3.241.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) From f7427c6b5d64df4cb4e2c0e833d59aec9827a55c Mon Sep 17 00:00:00 2001 From: Nextcloud bot Date: Wed, 14 Jan 2026 02:51:04 +0000 Subject: [PATCH 031/125] fix(l10n): Update translations from Transifex Signed-off-by: Nextcloud bot Signed-off-by: Raphael Vieira --- app/src/main/res/values-de/strings.xml | 3 ++- app/src/main/res/values-gl/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 3 +++ app/src/main/res/values-zh-rTW/strings.xml | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1fea68696063..f3c3e4a7ed23 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -195,7 +195,7 @@ Möchten Sie die ausgewählten Elemente und deren inhalt wirklich löschen? Nur lokal Konfliktlösungsdialog konnte nicht erstellt werden - Ordnerkonfilikt + Ordnerkonflikt Lokale Datei Falls beide Versionen gewählt werden, wird bei der lokalen Datei eine Zahl am Ende des Dateinamens hinzugefügt. Falls beide Versionen gewählt werden, wird beim lokalen Ordner eine Zahl am Ende des Dateinamens hinzugefügt. @@ -755,6 +755,7 @@ Intervall Interne Ordner für 2-Wege-Synchronisierung verwalten 2-Wege-Synchronisierung aktivieren + 2-Wege-Synchronisierung Dunkel Hell Systemvorgaben verwenden diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 54feeb801358..5e6964c11180 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -756,6 +756,7 @@ Intervalo Xestionar os cartafoles internos para a sincronización bidireccional Activar a sincronización bidireccional + Sincronización bidireccional Escuro Claro Seguir o sistema diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 8feb611ea6d5..f399a71ec6b1 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -685,6 +685,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Tentang Rincian Pengembang + File Umum Lainnya Sinkron @@ -875,6 +876,8 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Daftar dengan penyedia Izinkan %1$s untuk mengakses akun NextCloud %2$s? Urut berdasarkan + Urutkan favorit terlebih dahulu + Urutkan folder sebelum file Sembunyikan Rincian Identitas server tidak dapat diverifikasi diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a7b35b245d80..098746c4423a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -755,6 +755,7 @@ 間隔 管理內部資料夾以進行雙向同步 啟用雙向同步 + 雙向同步 深色 淺色 跟隨系統 From 0d0074257e74c46f2f349b7f49651e6fe8c3229b Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Thu, 9 Oct 2025 14:45:47 +0200 Subject: [PATCH 032/125] Onetime apppassword Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- .../authentication/AuthenticatorActivity.java | 37 ++++++++++++++++++- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 16 ++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java index 14eb2eec5d38..071c03353f28 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java @@ -53,6 +53,7 @@ import com.google.gson.reflect.TypeToken; import com.nextcloud.android.common.ui.color.ColorUtil; import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.nextcloud.android.lib.resources.users.GenerateOneTimeAppPasswordRemoteOperation; import com.nextcloud.client.account.User; import com.nextcloud.client.account.UserAccountManager; import com.nextcloud.client.device.DeviceInfo; @@ -61,6 +62,7 @@ import com.nextcloud.client.onboarding.FirstRunActivity; import com.nextcloud.client.onboarding.OnboardingService; import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.common.NextcloudClient; import com.nextcloud.common.PlainClient; import com.nextcloud.operations.PostMethod; import com.nextcloud.utils.extensions.BundleExtensionsKt; @@ -137,6 +139,7 @@ import androidx.lifecycle.LifecycleEventObserver; import androidx.lifecycle.ProcessLifecycleOwner; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import okhttp3.Credentials; import okhttp3.FormBody; import okhttp3.RequestBody; @@ -1594,11 +1597,43 @@ private void startQRScanner() { accountManager.getAccounts().length == 1) { DisplayUtils.showSnackMessage(this, R.string.no_mutliple_accounts_allowed); } else { - parseAndLoginFromWebView(resultData); + String onetimePrefix = getString(R.string.login_data_own_scheme) + PROTOCOL_SUFFIX + "onetime-login/"; + + if (resultData.startsWith(onetimePrefix)) { + parseAndLoginFromOneTimeCode(onetimePrefix, resultData); + } else { + parseAndLoginFromWebView(resultData); + } } } }); + private void parseAndLoginFromOneTimeCode(String onetimePrefix, String resultData) { + LoginUrlInfo loginUrlInfo = parseLoginDataUrl(onetimePrefix, resultData); + + GenerateOneTimeAppPasswordRemoteOperation generateOneTimeAppPasswordRemoteOperation = new GenerateOneTimeAppPasswordRemoteOperation(); + + String credentials = Credentials.basic(loginUrlInfo.getLoginName(), loginUrlInfo.getAppPassword()); + NextcloudClient nextcloudClient = new NextcloudClient(Uri.parse(loginUrlInfo.getServer()), loginUrlInfo.getLoginName(), credentials, this); + + new Thread(() -> { + RemoteOperationResult otpResult = nextcloudClient.execute(generateOneTimeAppPasswordRemoteOperation); + + if (otpResult.isSuccess()) { + mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(loginUrlInfo.getServer()); + webViewUser = loginUrlInfo.getLoginName(); + webViewPassword = otpResult.getResultData(); + + runOnUiThread(this::checkOcServer); + } else { + mServerStatusIcon = R.drawable.ic_alert; + mServerStatusText = getString(R.string.qr_could_not_be_read); + + runOnUiThread(this::showServerStatus); + } + }).start(); + } + @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e9374986770..ad93c15c00dd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidCommonLibraryVersion = "0.31.0" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" -androidLibraryVersion = "f4feed395eb081face0490447c07ce4ecd293baa" +androidLibraryVersion = "c112fd86c76f429db250e6abca711348e5534c0a" androidPluginVersion = '8.13.2' androidsvgVersion = "1.4" androidxMediaVersion = "1.5.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4430400e843d..3ece291a172e 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -20243,6 +20243,14 @@ + + + + + + + + @@ -20328,6 +20336,14 @@ + + + + + + + + From 78a942c900fcbe23824df5a2ece7923ff468d50a Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 13 Jan 2026 14:40:57 +0100 Subject: [PATCH 033/125] style(avatar): respect avatar border for TextDrawable ...since they can't resize automatically, so the border must be calculated into the radius Signed-off-by: Andy Scherzinger Signed-off-by: Raphael Vieira --- .../owncloud/android/ui/AvatarGroupLayout.kt | 3 +- .../owncloud/android/utils/DisplayUtils.java | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt index b57e29a083b6..27beb655fb0f 100644 --- a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt +++ b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt @@ -111,7 +111,8 @@ class AvatarGroupLayout @JvmOverloads constructor( avatarRadius, resources, avatar, - context + context, + avatarBorderSize ) } } diff --git a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java index f6f43e933140..1a232702f174 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -460,6 +460,7 @@ public static void setAvatar(@NonNull User user, @NonNull String userId, AvatarG * @param avatarRadius the avatar radius * @param resources reference for density information * @param callContext which context is called to set the generated avatar + * @param context general context */ public static void setAvatar(@NonNull User user, @NonNull String userId, @@ -469,6 +470,30 @@ public static void setAvatar(@NonNull User user, Resources resources, Object callContext, Context context) { + setAvatar(user, userId, displayName, listener, avatarRadius, resources, callContext, context, 0); + } + + /** + * fetches and sets the avatar of the given account in the passed callContext + * + * @param user the account to be used to connect to server + * @param userId the userId which avatar should be set + * @param displayName displayName used to generate avatar with first char, only used as fallback + * @param avatarRadius the avatar radius + * @param resources reference for density information + * @param callContext which context is called to set the generated avatar + * @param context general context + * @param avatarBorder value in case the avatar has a border, like in the case of the AvatarGroupLayout + */ + public static void setAvatar(@NonNull User user, + @NonNull String userId, + String displayName, + AvatarGenerationListener listener, + float avatarRadius, + Resources resources, + Object callContext, + Context context, + int avatarBorder) { if (callContext instanceof View v) { v.setContentDescription(String.valueOf(user.toPlatformAccount().hashCode())); } @@ -495,7 +520,8 @@ public static void setAvatar(@NonNull User user, // if no one exists, show colored icon with initial char if (avatar == null) { try { - avatar = TextDrawable.createAvatarByUserId(displayName, avatarRadius); + avatar = TextDrawable.createAvatarByUserId(displayName, + (avatarRadius - avatarBorder)); } catch (Exception e) { Log_OC.e(TAG, "Error calculating RGB value for active account icon.", e); avatar = ResourcesCompat.getDrawable(resources, From dddd115f8cb13705309174106613fdd393447465 Mon Sep 17 00:00:00 2001 From: alperozturk Date: Mon, 12 Jan 2026 11:22:38 +0100 Subject: [PATCH 034/125] fix: auto upload worker running check Signed-off-by: alperozturk Signed-off-by: Raphael Vieira --- .../java/com/nextcloud/client/jobs/BackgroundJobManager.kt | 2 +- .../com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt | 4 ++-- .../com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index 219de803ecda..abde68ad4040 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -165,7 +165,7 @@ interface BackgroundJobManager { fun cancelAllJobs() fun schedulePeriodicHealthStatus() fun startHealthStatus() - fun bothFilesSyncJobsRunning(syncedFolderID: Long): Boolean + fun isAutoUploadWorkerRunning(syncedFolderID: Long): Boolean fun startOfflineOperations() fun startPeriodicallyOfflineOperation() fun scheduleInternal2WaySync(intervalMinutes: Long) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index ed9aaefa6eff..0ad01e66c7ad 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -421,8 +421,8 @@ internal class BackgroundJobManagerImpl( workManager.cancelJob(JOB_PERIODIC_CALENDAR_BACKUP, user) } - override fun bothFilesSyncJobsRunning(syncedFolderID: Long): Boolean = - workManager.isWorkRunning(JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID) && + override fun isAutoUploadWorkerRunning(syncedFolderID: Long): Boolean = + workManager.isWorkRunning(JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID) || workManager.isWorkRunning(JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID) override fun startPeriodicallyOfflineOperation() { diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index b13ebabe7578..2c54909872ae 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -196,7 +196,7 @@ class AutoUploadWorker( return true } - if (backgroundJobManager.bothFilesSyncJobsRunning(syncedFolderID)) { + if (backgroundJobManager.isAutoUploadWorkerRunning(syncedFolderID)) { Log_OC.w(TAG, "🚧 another worker is already running for $syncedFolderID") return true } From e77de631998654630a90d7e4fa410e0a45e01a51 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 13 Jan 2026 12:09:12 +0100 Subject: [PATCH 035/125] fix Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../jobs/autoUpload/AutoUploadWorker.kt | 34 +++++-------------- ...FolderDownloadWorkerNotificationManager.kt | 9 ----- .../notification/WorkerNotificationManager.kt | 10 ++++++ 3 files changed, 18 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index 2c54909872ae..17b7a7ebad6f 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -25,12 +25,10 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager 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 @@ -85,8 +83,6 @@ class AutoUploadWorker( syncedFolder = syncedFolderProvider.getSyncedFolderByID(syncFolderId) ?.takeIf { it.isEnabled } ?: return Result.failure() - trySetForeground() - /** * Receives from [com.nextcloud.client.jobs.ContentObserverWork.checkAndTriggerAutoUpload] */ @@ -97,7 +93,6 @@ class AutoUploadWorker( } collectFileChangesFromContentObserverWork(contentUris) - updateNotification() uploadFiles(syncedFolder) Log_OC.d(TAG, "✅ ${syncedFolder.remotePath} finished checking files.") @@ -109,18 +104,11 @@ class AutoUploadWorker( } override suspend fun getForegroundInfo(): ForegroundInfo { - val notification = createNotification( - context.getString(R.string.upload_files) - ) - - return ForegroundServiceHelper.createWorkerForegroundInfo( - NOTIFICATION_ID, - notification, - ForegroundServiceType.DataSync - ) + val notification = createNotification(context.getString(R.string.upload_files)) + return notificationManager.getForegroundInfo(notification) } - private fun updateNotification() { + private suspend fun updateNotification() { getStartNotificationTitle()?.let { (localFolderName, remoteFolderName) -> try { val startNotification = createNotification( @@ -131,7 +119,7 @@ class AutoUploadWorker( ) ) - notificationManager.showNotification(startNotification) + setForeground(notificationManager.getForegroundInfo(startNotification)) } catch (e: Exception) { Log_OC.w(TAG, "⚠️ Could not update notification: ${e.message}") } @@ -141,21 +129,12 @@ class AutoUploadWorker( private suspend fun trySetForeground() { try { val notification = createNotification(context.getString(R.string.upload_files)) - updateForegroundInfo(notification) + setForeground(notificationManager.getForegroundInfo(notification)) } catch (e: Exception) { Log_OC.w(TAG, "⚠️ Could not set foreground service: ${e.message}") } } - private suspend fun updateForegroundInfo(notification: Notification) { - val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( - NOTIFICATION_ID, - notification, - ForegroundServiceType.DataSync - ) - setForeground(foregroundInfo) - } - private fun createNotification(title: String): Notification = notificationManager.notificationBuilder .setContentTitle(title) .setSmallIcon(R.drawable.uploads) @@ -297,6 +276,9 @@ class AutoUploadWorker( val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light) val currentLocale = context.resources.configuration.locales[0] + trySetForeground() + updateNotification() + var lastId = 0 while (true) { val filePathsWithIds = repository.getFilePathsWithIds(syncedFolder, lastId) diff --git a/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerNotificationManager.kt index 8502f4632ad6..fed0b8feaff0 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/folderDownload/FolderDownloadWorkerNotificationManager.kt @@ -13,9 +13,7 @@ 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 @@ -109,13 +107,6 @@ class FolderDownloadWorkerNotificationManager(private val context: Context, view return getForegroundInfo(notification) } - fun getForegroundInfo(notification: Notification): ForegroundInfo = - ForegroundServiceHelper.createWorkerForegroundInfo( - NOTIFICATION_ID, - notification, - ForegroundServiceType.DataSync - ) - fun dismiss() { notificationManager.cancel(NOTIFICATION_ID) } diff --git a/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt index 573e1d1d02ac..e5c9f31bb73b 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt @@ -14,7 +14,10 @@ import android.graphics.BitmapFactory import android.os.Handler import android.os.Looper import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import com.nextcloud.utils.ForegroundServiceHelper import com.owncloud.android.R +import com.owncloud.android.datamodel.ForegroundServiceType import com.owncloud.android.utils.theme.ViewThemeUtils open class WorkerNotificationManager( @@ -75,4 +78,11 @@ open class WorkerNotificationManager( fun getId(): Int = id fun getNotification(): Notification = notificationBuilder.build() + + fun getForegroundInfo(notification: Notification): ForegroundInfo = + ForegroundServiceHelper.createWorkerForegroundInfo( + id, + notification, + ForegroundServiceType.DataSync + ) } From 8bfd54cc0f8ceacf09f6d507d53fdc0e984279fc Mon Sep 17 00:00:00 2001 From: alperozturk Date: Mon, 12 Jan 2026 09:49:15 +0100 Subject: [PATCH 036/125] do not show all sync conflict notifications during app launch Signed-off-by: alperozturk --- app/src/main/AndroidManifest.xml | 3 + .../client/jobs/upload/FileUploadHelper.kt | 12 +++ .../AppWideNotificationManager.kt | 79 +++++++++++++++++++ ...ncConflictNotificationBroadcastReceiver.kt | 33 ++++++++ 4 files changed, 127 insertions(+) create mode 100644 app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt create mode 100644 app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 940739adb944..6a210d519cb1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -288,6 +288,9 @@ android:exported="false" tools:replace="android:exported" /> + diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 9ca2d89a972a..9409143d5b92 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -21,6 +21,7 @@ import com.nextcloud.client.jobs.BackgroundJobManager import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.activeUploadFileOperations import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.notifications.AppWideNotificationManager import com.nextcloud.utils.extensions.getUploadIds import com.owncloud.android.MainApp import com.owncloud.android.R @@ -170,6 +171,7 @@ class FileUploadHelper { uploads: Array ): Boolean { var showNotExistMessage = false + var showSyncConflictNotification = false val isOnline = checkConnectivity(connectivityService) val connectivity = connectivityService.connectivity val batteryStatus = powerManagementService.battery @@ -177,6 +179,12 @@ class FileUploadHelper { val uploadsToRetry = mutableListOf() for (upload in uploads) { + if (upload.lastResult == UploadResult.SYNC_CONFLICT) { + Log_OC.d(TAG, "retry upload skipped, sync conflict: ${upload.remotePath}") + showSyncConflictNotification = true + continue + } + val uploadResult = checkUploadConditions( upload, connectivity, @@ -214,6 +222,10 @@ class FileUploadHelper { ) } + if (showSyncConflictNotification) { + AppWideNotificationManager.showSyncConflictNotification(MainApp.getAppContext()) + } + return showNotExistMessage } diff --git a/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt new file mode 100644 index 000000000000..929d2dc0331d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.notifications + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.nextcloud.client.notifications.action.SyncConflictNotificationBroadcastReceiver +import com.owncloud.android.R +import com.owncloud.android.ui.activity.UploadListActivity +import com.owncloud.android.ui.notifications.NotificationUtils + +/** + * Responsible for showing **app-wide notifications** in the app. + * + * This manager provides a centralized place to create and display notifications + * that are not tied to a specific screen or feature. + * + */ +object AppWideNotificationManager { + + private const val SYNC_CONFLICT_NOTIFICATION_INTENT_REQ_CODE = 16 + private const val SYNC_CONFLICT_NOTIFICATION_INTENT_ACTION_REQ_CODE = 17 + + private const val SYNC_CONFLICT_NOTIFICATION_ID = 112 + + fun showSyncConflictNotification(context: Context) { + val intent = Intent(context, UploadListActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + SYNC_CONFLICT_NOTIFICATION_INTENT_REQ_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val actionIntent = Intent(context, SyncConflictNotificationBroadcastReceiver::class.java).apply { + putExtra(SyncConflictNotificationBroadcastReceiver.NOTIFICATION_ID, SYNC_CONFLICT_NOTIFICATION_ID) + } + + val actionPendingIntent = PendingIntent.getBroadcast( + context, + SYNC_CONFLICT_NOTIFICATION_INTENT_ACTION_REQ_CODE, + actionIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD) + .setSmallIcon(R.drawable.uploads) + .setContentTitle(context.getString(R.string.uploader_upload_failed_sync_conflict_error)) + .setContentText(context.getString(R.string.upload_conflict_message)) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.upload_conflict_message)) + ) + .addAction( + R.drawable.ic_cloud_upload, + context.getString(R.string.upload_list_resolve_conflict), + actionPendingIntent + ) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + NotificationManagerCompat.from(context) + .notify(SYNC_CONFLICT_NOTIFICATION_ID, notification) + } +} diff --git a/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt b/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt new file mode 100644 index 000000000000..db067a497b9e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/notifications/action/SyncConflictNotificationBroadcastReceiver.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.notifications.action + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.owncloud.android.ui.activity.UploadListActivity + +class SyncConflictNotificationBroadcastReceiver : BroadcastReceiver() { + companion object { + const val NOTIFICATION_ID = "NOTIFICATION_ID" + } + + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) + + if (notificationId != -1) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + + val intent = Intent(context, UploadListActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + context.startActivity(intent) + } +} From 33c50c1b7903a5047d4ce62aaa7bb472bd9bc258 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 14 Jan 2026 08:54:59 +0100 Subject: [PATCH 037/125] fix: lint Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../AppWideNotificationManager.kt | 25 ++++++++++++++++--- app/src/main/res/values/strings.xml | 4 +++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt index 929d2dc0331d..5a1ad0a6ada0 100644 --- a/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt @@ -7,13 +7,18 @@ package com.nextcloud.client.notifications +import android.Manifest import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import androidx.annotation.RequiresPermission +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.nextcloud.client.notifications.action.SyncConflictNotificationBroadcastReceiver import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.UploadListActivity import com.owncloud.android.ui.notifications.NotificationUtils @@ -26,11 +31,14 @@ import com.owncloud.android.ui.notifications.NotificationUtils */ object AppWideNotificationManager { + private const val TAG = "AppWideNotificationManager" + private const val SYNC_CONFLICT_NOTIFICATION_INTENT_REQ_CODE = 16 private const val SYNC_CONFLICT_NOTIFICATION_INTENT_ACTION_REQ_CODE = 17 private const val SYNC_CONFLICT_NOTIFICATION_ID = 112 + fun showSyncConflictNotification(context: Context) { val intent = Intent(context, UploadListActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -56,15 +64,15 @@ object AppWideNotificationManager { val notification = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD) .setSmallIcon(R.drawable.uploads) - .setContentTitle(context.getString(R.string.uploader_upload_failed_sync_conflict_error)) - .setContentText(context.getString(R.string.upload_conflict_message)) + .setContentTitle(context.getString(R.string.sync_conflict_notification_title)) + .setContentText(context.getString(R.string.sync_conflict_notification_description)) .setStyle( NotificationCompat.BigTextStyle() - .bigText(context.getString(R.string.upload_conflict_message)) + .bigText(context.getString(R.string.sync_conflict_notification_description)) ) .addAction( R.drawable.ic_cloud_upload, - context.getString(R.string.upload_list_resolve_conflict), + context.getString(R.string.sync_conflict_notification_action_title), actionPendingIntent ) .setContentIntent(pendingIntent) @@ -73,6 +81,15 @@ object AppWideNotificationManager { .setPriority(NotificationCompat.PRIORITY_DEFAULT) .build() + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Log_OC.w(TAG, "cannot show sync conflict notification, post notification permission is not granted") + return + } + NotificationManagerCompat.from(context) .notify(SYNC_CONFLICT_NOTIFICATION_ID, notification) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c6061c81668..c2794b7efc99 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1476,4 +1476,8 @@ Cannot open file chooser Failed to start action! Action triggered + + File upload conflicts + Upload conflicts detected. Open uploads to resolve. + Resolve conflicts From 75cbda54bf4adc64dd7cf7c1b8aa2c63546948fe Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 14 Jan 2026 08:55:28 +0100 Subject: [PATCH 038/125] fix: lint Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/notifications/AppWideNotificationManager.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt index 5a1ad0a6ada0..f58ad90ef0a4 100644 --- a/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/notifications/AppWideNotificationManager.kt @@ -12,7 +12,6 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -38,7 +37,6 @@ object AppWideNotificationManager { private const val SYNC_CONFLICT_NOTIFICATION_ID = 112 - fun showSyncConflictNotification(context: Context) { val intent = Intent(context, UploadListActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP From 61bf2508eee53bdd9eb7d17ef0472feb14e29d2a Mon Sep 17 00:00:00 2001 From: ZetaTom <70907959+ZetaTom@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:21:20 +0100 Subject: [PATCH 039/125] Show no results page when no results are available. - results page remained blank when no results were found - improve readability Signed-off-by: ZetaTom <70907959+ZetaTom@users.noreply.github.com> Signed-off-by: Raphael Vieira --- .../ui/adapter/UnifiedSearchListAdapter.kt | 2 +- .../ui/fragment/UnifiedSearchFragment.kt | 23 ++++++++----------- .../unifiedsearch/UnifiedSearchViewModel.kt | 6 +---- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt index baf0e9e79123..6e5b0c6a4f20 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt @@ -220,7 +220,7 @@ class UnifiedSearchListAdapter( notifyDataSetChanged() } - fun isCurrentDirItemsEmpty(): Boolean = currentDirItems.isEmpty() + fun hasLocalResults(): Boolean = currentDirItems.isNotEmpty() init { // initialise thumbnails cache on background thread diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt index 833db9694dc1..74f9248640e3 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt @@ -318,21 +318,18 @@ class UnifiedSearchFragment : binding.swipeContainingList.isRefreshing = loading } - PairMediatorLiveData(vm.searchResults, vm.isLoading).observe(viewLifecycleOwner) { pair -> - if (pair.second == false) { - var count = 0 + PairMediatorLiveData(vm.searchResults, vm.isLoading).observe(viewLifecycleOwner) { (searchResults, isLoading) -> + if (isLoading == true || searchResults.isNullOrEmpty()) { + return@observe + } - pair.first?.forEach { - count += it.entries.size - } + val hasSearchResult = searchResults.any { searchResult -> searchResult.entries.isNotEmpty() } - if (count == 0 && - pair.first?.isNotEmpty() == true && - context != null && - !adapter.isCurrentDirItemsEmpty() - ) { - showNoResult() - } + if (context != null && + !hasSearchResult && + !adapter.hasLocalResults() + ) { + showNoResult() } } diff --git a/app/src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt b/app/src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt index 97ba8e494538..4afb9ed10948 100644 --- a/app/src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt +++ b/app/src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt @@ -37,11 +37,7 @@ class UnifiedSearchViewModel(application: Application) : } private data class UnifiedSearchMetadata(var results: MutableList = mutableListOf()) { - fun nextCursor(): Int? = try { - results.lastOrNull()?.cursor?.toInt() - } catch (e: NumberFormatException) { - null - } + fun nextCursor(): Int? = results.lastOrNull()?.cursor?.toIntOrNull() fun name(): String? = results.lastOrNull()?.name } From 8f41347398141d470b61327b2a44a8ad2a957519 Mon Sep 17 00:00:00 2001 From: alperozturk Date: Mon, 12 Jan 2026 08:54:40 +0100 Subject: [PATCH 040/125] fix(auto-upload): handle existing sync conflicts Signed-off-by: alperozturk Signed-off-by: Raphael Vieira --- .../jobs/autoUpload/AutoUploadWorker.kt | 20 ++++++++++++++++--- .../jobs/autoUpload/FileSystemRepository.kt | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index 17b7a7ebad6f..176766c5aa95 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -34,6 +34,7 @@ import com.owncloud.android.datamodel.SyncedFolder import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult import com.owncloud.android.lib.common.OwnCloudAccount import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.utils.Log_OC @@ -302,7 +303,14 @@ class AutoUploadWorker( ) try { - var (uploadEntity, upload) = createEntityAndUpload(user, localPath, remotePath) + val result = createEntityAndUpload(user, localPath, remotePath) + if (result == null) { + repository.markFileAsHandled(localPath, syncedFolder) + Log_OC.d(TAG, "Marked file as handled due to existing conflict: $localPath") + continue + } + + var (uploadEntity, upload) = result // if local file deleted, upload process cannot be started or retriable thus needs to be removed if (path.isEmpty() || !file.exists()) { @@ -331,7 +339,7 @@ class AutoUploadWorker( ) if (result.isSuccess) { - repository.markFileAsUploaded(localPath, syncedFolder) + repository.markFileAsHandled(localPath, syncedFolder) Log_OC.d(TAG, "✅ upload completed: $localPath") } else { Log_OC.e( @@ -375,7 +383,7 @@ class AutoUploadWorker( uploadsStorageManager.removeUpload(upload) } - private fun createEntityAndUpload(user: User, localPath: String, remotePath: String): Pair { + private fun createEntityAndUpload(user: User, localPath: String, remotePath: String): Pair? { val (needsCharging, needsWifi, uploadAction) = getUploadSettings(syncedFolder) Log_OC.d(TAG, "creating oc upload for ${user.accountName}") @@ -386,6 +394,12 @@ class AutoUploadWorker( accountName = user.accountName ) + val lastUploadResult = uploadEntity?.lastResult?.let { UploadResult.fromValue(it) } + if (lastUploadResult == UploadResult.SYNC_CONFLICT) { + Log_OC.w(TAG, "Conflict already exists, skipping auto-upload: $localPath") + return null + } + val upload = ( uploadEntity?.toOCUpload(null) ?: OCUpload( localPath, diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt index b322b1bc7fc4..9ff924c6aadf 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt @@ -63,7 +63,7 @@ class FileSystemRepository(private val dao: FileSystemDao, private val context: return filtered } - suspend fun markFileAsUploaded(localPath: String, syncedFolder: SyncedFolder) { + suspend fun markFileAsHandled(localPath: String, syncedFolder: SyncedFolder) { val syncedFolderIdStr = syncedFolder.id.toString() try { From 5c5fea4028d185c73e0b354dfeb7fc9fde7dea75 Mon Sep 17 00:00:00 2001 From: alperozturk Date: Mon, 12 Jan 2026 08:55:47 +0100 Subject: [PATCH 041/125] fix(auto-upload): codacy Signed-off-by: alperozturk Signed-off-by: Raphael Vieira --- .../nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index 176766c5aa95..77d9efc41695 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -383,7 +383,11 @@ class AutoUploadWorker( uploadsStorageManager.removeUpload(upload) } - private fun createEntityAndUpload(user: User, localPath: String, remotePath: String): Pair? { + private fun createEntityAndUpload( + user: User, + localPath: String, + remotePath: String + ): Pair? { val (needsCharging, needsWifi, uploadAction) = getUploadSettings(syncedFolder) Log_OC.d(TAG, "creating oc upload for ${user.accountName}") From 0f5474908cac1bdfaade0d4d7da71d4afb3ac864 Mon Sep 17 00:00:00 2001 From: Nextcloud bot Date: Thu, 15 Jan 2026 02:52:14 +0000 Subject: [PATCH 042/125] fix(l10n): Update translations from Transifex Signed-off-by: Nextcloud bot Signed-off-by: Raphael Vieira --- app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-et-rEE/strings.xml | 3 +++ app/src/main/res/values-gl/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 3 +++ app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 5 files changed, 12 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f3c3e4a7ed23..402f2c2b1069 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -13,6 +13,7 @@ Senden/Teilen Kachelansicht Listenansicht + Aktion ausgelöst Kontakte und Kalender wiederherstellen Neuer Ordner Verschieben oder kopieren @@ -396,6 +397,7 @@ Die Erstellung des Konfliktdialogs ist fehlgeschlagen Datei konnte nicht an den Downloadmanager übergeben werden Datei konnte nicht gedruckt werden + Aktion konnte nicht gestartet werden! Editor konnte nicht gestartet werden Oberfläche konnte nicht aktualisiert werden Zu den Favoriten hinzufügen diff --git a/app/src/main/res/values-et-rEE/strings.xml b/app/src/main/res/values-et-rEE/strings.xml index aca0ec75b651..b614b47a77a8 100644 --- a/app/src/main/res/values-et-rEE/strings.xml +++ b/app/src/main/res/values-et-rEE/strings.xml @@ -13,6 +13,7 @@ Saada või jaga Ruudustikvaade Loendivaade + Tegevus käivitatud Taasta kontaktid ja kalender Uus kaust Teisalda või kopeeri @@ -396,6 +397,7 @@ Failikonfilktiteabe vaate loomine ei õnnestu Ei õnnestunud faili allalaadimishaldurile edasi anda Faili trükkimine ei õnnestunud + Tegevuse käivitamine ei õnnestunud! Ei õnnestunud avada muutmisvaadet Kasutajaliidese uuendamine ei õnnestunud Lisa lemmikutesse @@ -755,6 +757,7 @@ Välp Halda sisemisi kaustu kahepoolse sünkroonimise jaoks Luba kahepoolne sünkroonimine + Kahepoolne sünkroonimine Hele Tume Kasuta süsteemi kujundust diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 5e6964c11180..eeb1b07a788f 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -13,6 +13,7 @@ Enviar/compartir Ver como grade Ver como lista + Acción activada Restaurar contactos e calendario Novo cartafol Mover ou copiar @@ -396,6 +397,7 @@ Produciuse un fallo ao crear o diálogo de conflito Produciuse un fallo ao pasar o ficheiro para o xestor de descargas Produciuse un fallo ao imprimir o ficheiro + Produciuse un fallo ao iniciar a acción! Produciuse un fallo ao iniciar o editor Produciuse un fallo ao actualizar a IU Engadir a favoritos diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b7ff9a8698ea..1554e5397a9f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -13,6 +13,7 @@ Enviar/compartilhar Vista em grade Lista de visualização + Ação acionada Restaurar contatos e calendário Nova pasta Mover ou copiar @@ -396,6 +397,7 @@ Falha ao criar caixa de diálogo de conflito Erro ao enviar o arquivo ao gerenciador de downloads Erro ao imprimir o arquivo + Falha ao iniciar a ação! Erro ao iniciar editor Falha ao atualizar a interface gráfica Adicionar aos favoritos @@ -755,6 +757,7 @@ Intervalo Gerenciar pastas internas para sincronização bidirecional Ativar sincronização bidirecional + Sincronização bidirecional Escuro Claro Seguir o sistema diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 098746c4423a..e75b8adcbb48 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -13,6 +13,7 @@ 傳送/分享 格狀檢視 清單檢視 + 觸發動作 復原聯絡人與行事曆 新資料夾 移動或複製 @@ -396,6 +397,7 @@ 無法建立衝突對話方塊 無法將檔案傳遞給下載管理程式 檔案列印失敗 + 開始動作失敗! 無法啟動編輯器 更新使用者介面失敗 新增至喜愛 From c68cb19104e94aa73b99966396e994c3fb8fac9a Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 24 Aug 2025 15:21:10 +0200 Subject: [PATCH 043/125] Minor improvements of testing documentation - Fixed instructions for running tests. The mentioned example test had been refactored so the given commands were not able to locate a test to run. - Fixed copy/paste errors in test comments - Documented how to write server-based tests - Extended formatter setup documentation Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- CONTRIBUTING.md | 42 +++++++++++++++---- README.md | 1 + .../owncloud/android/AbstractOnServerIT.java | 12 ++++-- .../java/com/owncloud/android/DownloadIT.java | 2 +- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 157e4b18eddf..4b77f2d17e22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ 1. [Contributing to Source Code](#contributing-to-source-code) 1. [Developing process](#developing-process) 1. [Branching model](#branching-model) - 1. [Android Studio formatter setup](#android-studio-formatter-setup) + 1. [Formatter setup](#formatter-setup) 1. [Build variants](#build-variants) 1. [Git hooks](#git-hooks) 1. [Contribution process](#contribution-process) @@ -107,12 +107,22 @@ We are all about quality while not sacrificing speed so we use a very pragmatic * Hot fixes not relevant for an upcoming feature release but the latest release can target the bug fix branch directly -### Android Studio formatter setup +### Formatter setup Our formatter setup is rather simple: * Standard Android Studio -* Line length 120 characters (Settings->Editor->Code Style->Right margin(columns): 120) +* Line length 120 characters (Settings->Editor->Code Style->Right margin(columns): 120; also set by EditorConfig * Auto optimize imports (Settings->Editor->Auto Import->Optimize imports on the fly) +You can fix Check / check (spotlessKotlinCheck) via following commands: + +```bash +./gradlew spotlessApply +./gradlew detekt +./gradlew spotlessCheck +./gradlew spotlessKotlinCheck +``` + +See section [Git hooks](#git-hooks) to have these run automatically with your commits. ### Build variants There are three build variants @@ -122,7 +132,7 @@ There are three build variants ### Git hooks We provide git hooks to make development process easier for both the developer and the reviewers. -To install them, just run: +They are stored in [/scripts/hooks](/scripts/hooks) and can be installed with: ```bash ./gradlew installGitHooks @@ -214,21 +224,37 @@ Source code of app: - small, isolated tests, with no need of Android SDK - code coverage can be directly shown via right click on test and select "Run Test with Coverage" +``` +./gradlew jacocoTestGplayDebugUnitTest +```bash + #### Instrumented tests - tests to see larger code working in correct way - tests that require parts of Android SDK -- best to avoid server communication, see https://github.com/nextcloud/android/pull/3624 - run all tests ```./gradlew createGplayDebugCoverageReport -Pcoverage=true``` - run selective test class: ```./gradlew createGplayDebugCoverageReport -Pcoverage=true - -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerTest``` + -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerContentProviderClientIT``` - run multiple test classes: - separate by "," - - ```./gradlew createGplayDebugCoverageReport -Pcoverage=true -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerTest,com.nextcloud.client.FileDisplayActivityIT``` + - ```./gradlew createGplayDebugCoverageReport -Pcoverage=true -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerContentProviderClientIT,com.nextcloud.client.FileDisplayActivityIT``` - run one test in class: ```./gradlew createGplayDebugCoverageReport -Pcoverage=true - -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerTest#saveNewFile``` + -Pandroid.testInstrumentationRunnerArguments.class=com.owncloud.android.datamodel.FileDataStorageManagerContentProviderClientIT#saveFile``` - JaCoCo results are shown as html: firefox ./build/reports/coverage/gplay/debug/index.html +#### Instrumented tests with server communication +It is best to avoid server communication, see https://github.com/nextcloud/android/pull/3624. +But if a test requires a server, this is how it is done: +- Have a Nextcloud service reachable by your test device. This can be an existing server in the internet or a locally deployed one +as per the [server developer documentation](https://docs.nextcloud.com/server/latest/developer_manual/getting_started/devenv.html) +- Create a separate(!) test user on that server, otherwise the tests will infer with productive data. +- In `gradle.properties`, enter the URL, user name and password via the `NC_TEST_SERVER_...` attributes. + If you want to prevent an accidental commit of those, you can also store them in `~/.gradle/gradle.properties`. +- Your test class should inherit from `AbstractOnServerIT`, e.g.: `public class DownloadIT extends AbstractOnServerIT { ...` + Note that this will automatically delete all files after each test run, so you absolutely NEED a separate test user as mentioned above. +- All preconditions of your test regarding server data, e.g. existing files, need to be established by your test itself. + As a reference, see how `DownloadIT` first uploads the files it later tests the download with. +- Clean up these preconditions again, also in the failure case, using one or multiple `@After` methods in your test class. #### UI tests We use [shot](https://github.com/Karumi/Shot) for taking screenshots and compare them. diff --git a/README.md b/README.md index bd99dd07fe89..1a23a124eb06 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ If you want to [contribute](https://nextcloud.com/contribute/) to the Nextcloud * reporting problems / suggesting enhancements by [opening new issues](https://github.com/nextcloud/android/issues/new/choose) * implementing proposed bug fixes and enhancement ideas by submitting PRs (associated with a corresponding issue preferably) * reviewing [pull requests](https://github.com/nextcloud/android/pulls) and providing feedback on code, implementation, and functionality +* Add [automated tests](CONTRIBUTING.md#testing) for existing functionality * installing and testing [pull request builds](https://github.com/nextcloud/android/pulls), [daily/dev builds](https://github.com/nextcloud/android#development-version-hammer), or [RCs/release candidate builds](https://github.com/nextcloud/android/releases) * enhancing Admin, User, or Developer [documentation](https://github.com/nextcloud/documentation/) * hitting hard on the latest stable release by testing fundamental features and evaluating the user experience diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java index aef7eee1110c..207a7b80ba5d 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java @@ -53,9 +53,15 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -/** - * Common base for all integration tests. - */ +/// Common base for all integration tests requiring a server connection. +/// ATTENTION: Deletes ALL files of the test user on the server after each test run. +/// So you MUST use a dedicated test user. +/// Uses server, user and password given as `testInstrumentationRunnerArgument` +/// - TEST_SERVER_URL +/// - TEST_SERVER_USERNAME +/// - TEST_SERVER_PASSWORD +/// These are supplied via build.gradle, which takes them from gradle.properties. +/// So look in the latter file to set to your own server & test user. public abstract class AbstractOnServerIT extends AbstractIT { @BeforeClass public static void beforeAll() { diff --git a/app/src/androidTest/java/com/owncloud/android/DownloadIT.java b/app/src/androidTest/java/com/owncloud/android/DownloadIT.java index 610469f4d3fa..d3099b251562 100644 --- a/app/src/androidTest/java/com/owncloud/android/DownloadIT.java +++ b/app/src/androidTest/java/com/owncloud/android/DownloadIT.java @@ -29,7 +29,7 @@ import static org.junit.Assert.assertTrue; /** - * Tests related to file uploads. + * Tests related to file downloads. */ public class DownloadIT extends AbstractOnServerIT { private static final String FOLDER = "/testUpload/"; From 27cadc686ef754ad3986a67ab265d262e386296d Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 24 Aug 2025 17:38:57 +0200 Subject: [PATCH 044/125] Instrumentation tests now automatically grab the notifications permission Before that, when starting individual tests from the command line or from inside the IDE, they could fail because a dialog asking for the permission to post notifications was blocking the view. While we are on it, added a small explanation to the other existing rule. Without that explanation it might be unclear why this is not also done via the same GrantPermissionRule used for the notifications. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../java/com/nextcloud/test/GrantStoragePermissionRule.kt | 5 +++++ .../androidTest/java/com/owncloud/android/AbstractIT.java | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/nextcloud/test/GrantStoragePermissionRule.kt b/app/src/androidTest/java/com/nextcloud/test/GrantStoragePermissionRule.kt index b310a897fa42..3bade62247c4 100644 --- a/app/src/androidTest/java/com/nextcloud/test/GrantStoragePermissionRule.kt +++ b/app/src/androidTest/java/com/nextcloud/test/GrantStoragePermissionRule.kt @@ -15,6 +15,10 @@ import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement +/** + * Rule to automatically enable the test to write to the external storage. + * Depending on the SDK version, different approaches might be necessary to achieve the full access. + */ class GrantStoragePermissionRule private constructor() { companion object { @@ -30,6 +34,7 @@ class GrantStoragePermissionRule private constructor() { private class GrantManageExternalStoragePermissionRule : TestRule { override fun apply(base: Statement, description: Description): Statement = object : Statement() { override fun evaluate() { + // Refer to https://developer.android.com/training/data-storage/manage-all-files#enable-manage-external-storage-for-testing InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand( "appops set --uid ${InstrumentationRegistry.getInstrumentation().targetContext.packageName} " + "MANAGE_EXTERNAL_STORAGE allow" diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java index f7ab18fe76c1..58d6abef071c 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java @@ -6,6 +6,7 @@ */ package com.owncloud.android; +import android.Manifest; import android.accounts.Account; import android.accounts.AccountManager; import android.accounts.AuthenticatorException; @@ -78,6 +79,7 @@ import androidx.test.espresso.contrib.DrawerActions; import androidx.test.espresso.intent.rule.IntentsTestRule; import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.GrantPermissionRule; import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; import androidx.test.runner.lifecycle.Stage; @@ -93,7 +95,10 @@ */ public abstract class AbstractIT { @Rule - public final TestRule permissionRule = GrantStoragePermissionRule.grant(); + public final TestRule storagePermissionRule = GrantStoragePermissionRule.grant(); + + @Rule + public GrantPermissionRule notificationsPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS); protected static OwnCloudClient client; protected static NextcloudClient nextcloudClient; From 534b7f1d55a91be8f113058666514d03118544fb Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Tue, 28 Oct 2025 18:58:45 +0100 Subject: [PATCH 045/125] DownloadIT now works for all build variants Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- app/src/androidTest/java/com/owncloud/android/DownloadIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/DownloadIT.java b/app/src/androidTest/java/com/owncloud/android/DownloadIT.java index d3099b251562..f8fd99517f1c 100644 --- a/app/src/androidTest/java/com/owncloud/android/DownloadIT.java +++ b/app/src/androidTest/java/com/owncloud/android/DownloadIT.java @@ -98,10 +98,10 @@ private void verifyDownload(OCFile file1, OCFile file2) { assertTrue(new File(file2.getStoragePath()).exists()); // test against hardcoded path to make sure that it is correct - assertEquals("/storage/emulated/0/Android/media/com.nextcloud.client/nextcloud/" + + assertEquals("/storage/emulated/0/Android/media/"+targetContext.getPackageName()+"/nextcloud/" + Uri.encode(account.name, "@") + "/testUpload/nonEmpty.txt", file1.getStoragePath()); - assertEquals("/storage/emulated/0/Android/media/com.nextcloud.client/nextcloud/" + + assertEquals("/storage/emulated/0/Android/media/"+targetContext.getPackageName()+"/nextcloud/" + Uri.encode(account.name, "@") + "/testUpload/nonEmpty2.txt", file2.getStoragePath()); } From df83973c0a76c63c549ce6efa57dcb689427bc14 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 2 Nov 2025 18:58:43 +0100 Subject: [PATCH 046/125] Fixed AbstractIT deleting the wrong account The account type depends on the build flavor, as some of them define their own R.string.account_type. The test did respect that value when creating the account in AbstractIT.createAccount(), but not when deleting the account beforehand. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- app/src/androidTest/java/com/owncloud/android/AbstractIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java index 58d6abef071c..2c5fb271149c 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java @@ -123,7 +123,7 @@ public static void beforeAll() { AccountManager platformAccountManager = AccountManager.get(targetContext); for (Account account : platformAccountManager.getAccounts()) { - if (account.type.equalsIgnoreCase("nextcloud")) { + if (account.type.equalsIgnoreCase(MainApp.getAccountType(targetContext))) { platformAccountManager.removeAccountExplicitly(account); } } From 0c8e14a48531dab5269244aaf02b8861161038cf Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 2 Nov 2025 18:22:20 +0100 Subject: [PATCH 047/125] AbstractOnServerIT file deletion now ignores non-empty encrypted folders TODO: helper function to check mime type for folder should probably move to the RemoteFile class in the Nextcloud Library. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../com/owncloud/android/AbstractOnServerIT.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java index 207a7b80ba5d..4c18f6d227f3 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java @@ -36,6 +36,7 @@ import com.owncloud.android.lib.resources.files.model.RemoteFile; import com.owncloud.android.operations.RefreshFolderOperation; import com.owncloud.android.operations.UploadFileOperation; +import com.owncloud.android.utils.MimeType; import org.apache.commons.httpclient.HttpStatus; import org.apache.commons.httpclient.methods.GetMethod; @@ -127,6 +128,11 @@ public void after() { super.after(); } + private static boolean isFolder(RemoteFile file) { + // TODO: should probably move to RemoteFile class + return MimeType.DIRECTORY.equals(file.getMimeType()) || MimeType.WEBDAV_FOLDER.equals(file.getMimeType()); + } + public static void deleteAllFilesOnServer() { RemoteOperationResult result = new ReadFolderRemoteOperation("/").execute(client); assertTrue(result.getLogMessage(), result.isSuccess()); @@ -144,6 +150,13 @@ public static void deleteAllFilesOnServer() { .execute(client) .isSuccess(); + if (!operationResult && isFolder(remoteFile)) { + // Deleting encrypted folder is not possible due to bug + // https://github.com/nextcloud/end_to_end_encryption/issues/421 + // Toggling encryption also fails, when the folder is not empty. So we ignore this folder + continue; + } + assertTrue(operationResult); } From 211600c486133b8543fc329c0fc985f517dcac15 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sat, 20 Dec 2025 12:43:42 +0100 Subject: [PATCH 048/125] Fixed some static warnings in AbstractIT and AbstractOnServerIT - UserAccountManagerImpl#getAccountByName() is never null because since #13074 it rather returns an anonymous account. To detect an account lookup failure, the type needs to be compared - The getMaterialSchemesProvider() object returned null for most functions, even though they were annotated with @NonNull. Extracted the only actually used function getMaterialSchemesForCurrentUser() Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../java/com/owncloud/android/AbstractIT.java | 42 +++---------------- .../owncloud/android/AbstractOnServerIT.java | 16 +++---- .../android/ui/dialog/DialogFragmentIT.kt | 3 +- 3 files changed, 14 insertions(+), 47 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java index 2c5fb271149c..04482aca2cd2 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java @@ -12,7 +12,6 @@ import android.accounts.AuthenticatorException; import android.accounts.OperationCanceledException; import android.app.Activity; -import android.content.ActivityNotFoundException; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; @@ -56,7 +55,6 @@ import com.owncloud.android.operations.CreateFolderOperation; import com.owncloud.android.operations.UploadFileOperation; import com.owncloud.android.utils.FileStorageUtils; -import com.owncloud.android.utils.theme.MaterialSchemesProvider; import org.apache.commons.io.FileUtils; import org.junit.After; @@ -427,7 +425,7 @@ public boolean isPowerSavingEnabled() { newUpload.setRemoteFolderToBeCreated(); - RemoteOperationResult result = newUpload.execute(client); + var result = newUpload.execute(client); assertTrue(result.getLogMessage(), result.isSuccess()); } @@ -532,8 +530,8 @@ protected static Account createAccount(String name) { platformAccountManager.setUserData(temp, KEY_USER_ID, name.substring(0, atPos)); Account account = UserAccountManagerImpl.fromContext(targetContext).getAccountByName(name); - if (account == null) { - throw new ActivityNotFoundException(); + if (Objects.equals(account.type, targetContext.getString(R.string.anonymous_account_type))) { + throw new RuntimeException("Could not get account with name " + name); } return account; } @@ -542,37 +540,7 @@ protected static boolean removeAccount(Account account) { return AccountManager.get(targetContext).removeAccountExplicitly(account); } - protected MaterialSchemesProvider getMaterialSchemesProvider() { - return new MaterialSchemesProvider() { - @NonNull - @Override - public MaterialSchemes getMaterialSchemesForUser(@NonNull User user) { - return null; - } - - @NonNull - @Override - public MaterialSchemes getMaterialSchemesForCapability(@NonNull OCCapability capability) { - return null; - } - - @NonNull - @Override - public MaterialSchemes getMaterialSchemesForCurrentUser() { - return new MaterialSchemesImpl(R.color.primary, false); - } - - @NonNull - @Override - public MaterialSchemes getDefaultMaterialSchemes() { - return null; - } - - @NonNull - @Override - public MaterialSchemes getMaterialSchemesForPrimaryBackground() { - return null; - } - }; + protected MaterialSchemes getMaterialSchemesForCurrentUser() { + return new MaterialSchemesImpl(R.color.primary, false); } } diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java index 4c18f6d227f3..1b0e1b8d3c17 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java @@ -10,7 +10,6 @@ import android.accounts.AccountManager; import android.accounts.AuthenticatorException; import android.accounts.OperationCanceledException; -import android.content.ActivityNotFoundException; import android.net.Uri; import android.os.Bundle; @@ -29,7 +28,6 @@ import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.OwnCloudClientFactory; import com.owncloud.android.lib.common.accounts.AccountUtils; -import com.owncloud.android.lib.common.operations.RemoteOperationResult; import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation; import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation; import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation; @@ -46,6 +44,7 @@ import java.io.File; import java.io.IOException; +import java.util.Objects; import java.util.Optional; import androidx.annotation.NonNull; @@ -97,8 +96,8 @@ public static void beforeAll() { final UserAccountManager userAccountManager = UserAccountManagerImpl.fromContext(targetContext); account = userAccountManager.getAccountByName(loginName + "@" + baseUrl); - if (account == null) { - throw new ActivityNotFoundException(); + if (Objects.equals(account.type, targetContext.getString(R.string.anonymous_account_type))) { + throw new RuntimeException("Could not get account with name " + loginName + "@" + baseUrl); } Optional optionalUser = userAccountManager.getUser(account.name); @@ -134,13 +133,13 @@ private static boolean isFolder(RemoteFile file) { } public static void deleteAllFilesOnServer() { - RemoteOperationResult result = new ReadFolderRemoteOperation("/").execute(client); - assertTrue(result.getLogMessage(), result.isSuccess()); + var result = new ReadFolderRemoteOperation("/").execute(client); + assertTrue(result.getLogMessage(targetContext), result.isSuccess()); for (Object object : result.getData()) { RemoteFile remoteFile = (RemoteFile) object; - if (!remoteFile.getRemotePath().equals("/")) { + if (!Objects.equals(remoteFile.getRemotePath(), "/")) { if (remoteFile.isEncrypted()) { ToggleEncryptionRemoteOperation operation = new ToggleEncryptionRemoteOperation(remoteFile.getLocalId(), remoteFile.getRemotePath(), @@ -262,7 +261,7 @@ public boolean isPowerSavingEnabled() { newUpload.setRemoteFolderToBeCreated(); - RemoteOperationResult result = newUpload.execute(client); + var result = newUpload.execute(client); assertTrue(result.getLogMessage(), result.isSuccess()); OCFile parentFolder = getStorageManager() @@ -271,6 +270,7 @@ public boolean isPowerSavingEnabled() { OCFile uploadedFile = getStorageManager(). getFileByDecryptedRemotePath(parentFolder.getDecryptedRemotePath() + uploadedFileName); + assertNotNull(uploadedFile); assertNotNull(uploadedFile.getRemoteId()); assertNotNull(uploadedFile.getPermissions()); diff --git a/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt index 9f950eac778a..4f039da6bf63 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt @@ -504,9 +504,8 @@ class DialogFragmentIT : AbstractIT() { throw UnsupportedOperationException("Document scan is not available") } - val materialSchemesProvider = getMaterialSchemesProvider() val viewThemeUtils = ViewThemeUtils( - materialSchemesProvider.getMaterialSchemesForCurrentUser(), + materialSchemesForCurrentUser, ColorUtil(targetContext) ) From 6a97c43ed70db0135896a98d682f67873c469aff Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sat, 20 Dec 2025 12:46:36 +0100 Subject: [PATCH 049/125] Fixed incorrect lookup of gradle properties file Prior, tests couldn't reach the configured server, because the custom URL and credentials are simply ignored. 247d085 introduced an alternative parsing of the same values, but from .gradle/config.properties. Later, 5fd2e29 came and changed that testInstrumentationRunnerArgument wasn't taking these values from the gradle.properties but instead from the otherwise unused config.properties. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 057ec2bdd451..03efc38bca56 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -75,7 +75,7 @@ val ndkEnv = buildMap { } val configProps = Properties().apply { - val file = rootProject.file(".gradle/config.properties") + val file = rootProject.file("gradle.properties") if (file.exists()) load(FileInputStream(file)) } From fc014cfbfa8aaa78fc5e93910f7c1d4c07228a5f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 08:44:44 +0100 Subject: [PATCH 050/125] remove debug login Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- app/build.gradle.kts | 6 ------ .../authentication/AuthenticatorActivity.java | 16 ---------------- 2 files changed, 22 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03efc38bca56..a665df2c229a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,12 +136,6 @@ android { debug { enableUnitTestCoverage = project.hasProperty("coverage") resConfigs("xxxhdpi") - - buildConfigField( - "String", - "NC_TEST_SERVER_DATA_STRING", - "\"nc://login/user:${ncTestServerUsername}&password:${ncTestServerPassword}&server:${ncTestServerBaseUrl}\"" - ) } } diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java index 071c03353f28..3add5f5eef73 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java @@ -716,22 +716,6 @@ private void initOverallUi() { } else { accountSetupBinding.scanQr.setVisibility(View.GONE); } - - addDebugLogin(); - } - - private void addDebugLogin() { - if (BuildConfig.DEBUG) { - try { - accountSetupBinding.thumbnail.setOnLongClickListener(v -> { - final String dataString = BuildConfig.NC_TEST_SERVER_DATA_STRING; - parseAndLoginFromWebView(dataString); - return false; - }); - } catch (Throwable t) { - Log_OC.w(TAG, "Test server data string not available in this build"); - } - } } /** From f5de291ada11d89ef3c585f61dc3d3acb93a0075 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Mon, 22 Dec 2025 23:06:59 +0100 Subject: [PATCH 051/125] Test that move or copy files starts in the file's parent folder This tests the changes of #15925, where the old behavior of moving up to the root folder was changed to stay in the parent folder of the file(s) to move/copy. The new behavior is in sync with iOS and the Web UI, hence this test ensures it doesn't break Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../OCFileListFragmentStaticServerIT.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt index 9b908f53a860..700a9cf41841 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 Philipp Hasper * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2020 Tobias Kaminsky * SPDX-FileCopyrightText: 2020 Chris Narkiewicz @@ -12,19 +13,31 @@ package com.owncloud.android.ui.fragment import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import com.nextcloud.test.GrantStoragePermissionRule.Companion.grant import com.nextcloud.test.TestActivity import com.owncloud.android.AbstractIT +import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.resources.shares.ShareType import com.owncloud.android.lib.resources.shares.ShareeUser import com.owncloud.android.lib.resources.tags.Tag +import com.owncloud.android.ui.activity.FolderPickerActivity import com.owncloud.android.utils.EspressoIdlingResource import com.owncloud.android.utils.MimeType import com.owncloud.android.utils.ScreenshotTest +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.not import org.junit.Assert +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -35,6 +48,11 @@ class OCFileListFragmentStaticServerIT : AbstractIT() { @get:Rule var storagePermissionRule: TestRule = grant() + @Before + fun initIntentRecording() { + Intents.init() + } + @Test @ScreenshotTest @Suppress("MagicNumber") @@ -397,4 +415,50 @@ class OCFileListFragmentStaticServerIT : AbstractIT() { Assert.assertTrue(sut.adapter.shouldShowHeader()) } } + + @Test + fun shouldStartMoveInParentFolder() { + launchActivity().use { scenario -> + val fragment = OCFileListFragment() + var testFolder: OCFile? = null + + scenario.onActivity { activity -> + testFolder = OCFile("/folder/").apply { + setFolder() + } + activity.storageManager.saveNewFile(testFolder) + + val testFile = OCFile("${testFolder.remotePath}myImage.png").apply { + parentId = testFolder.fileId + activity.storageManager.saveNewFile(this) + } + + activity.addFragment(fragment) + activity.supportFragmentManager.executePendingTransactions() + + fragment.listDirectory(testFolder, false) + activity.supportFragmentManager.executePendingTransactions() + fragment.onFileActionChosen(R.id.action_move_or_copy, setOf(testFile)) + activity.supportFragmentManager.executePendingTransactions() + } + // Check that the FolderPickerActivity was opened + intended(hasComponent(FolderPickerActivity::class.java.canonicalName)) + + // Check that the Action Bar shows the current folder name as title + onView( + allOf( + isDescendantOfA(withId(R.id.toolbar)), + withText(testFolder!!.fileName) + ) + ).check(matches(isDisplayed())) + + // Test the button's enabled status. "Move" should not be enabled, but the rest should. + onView(allOf(withId(R.id.folder_picker_btn_cancel), isDisplayed())) + .check(matches(isEnabled())) + onView(allOf(withId(R.id.folder_picker_btn_copy), isDisplayed())) + .check(matches(isEnabled())) + onView(allOf(withId(R.id.folder_picker_btn_move), isDisplayed())) + .check(matches(not(isEnabled()))) + } + } } From d2dec9b79168dd2e9075308055e8ec008df288ce Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Thu, 15 Jan 2026 09:20:52 +0100 Subject: [PATCH 052/125] Removed unnecessary executePendingTransactions() Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../android/ui/fragment/OCFileListFragmentStaticServerIT.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt index 700a9cf41841..59bf0b97f64f 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt @@ -437,9 +437,7 @@ class OCFileListFragmentStaticServerIT : AbstractIT() { activity.supportFragmentManager.executePendingTransactions() fragment.listDirectory(testFolder, false) - activity.supportFragmentManager.executePendingTransactions() fragment.onFileActionChosen(R.id.action_move_or_copy, setOf(testFile)) - activity.supportFragmentManager.executePendingTransactions() } // Check that the FolderPickerActivity was opened intended(hasComponent(FolderPickerActivity::class.java.canonicalName)) From 763e2ef3c41e9df3a65fe7a625ba93750674666f Mon Sep 17 00:00:00 2001 From: nextcloud-android-bot Date: Thu, 15 Jan 2026 09:57:37 +0000 Subject: [PATCH 053/125] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'.githu?= =?UTF-8?q?b/workflows/'=20with=20remote=20'config/workflows/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nextcloud-android-bot Signed-off-by: Raphael Vieira --- .github/workflows/qa.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index ab1e890188f8..26664a44cb78 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -48,8 +48,10 @@ jobs: mkdir -p "$HOME/.gradle" echo "org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" > "$HOME/.gradle/gradle.properties" echo "org.gradle.caching=true; org.gradle.parallel=true; org.gradle.configureondemand=true; kapt.incremental.apt=true" >> "$HOME/.gradle/gradle.properties" - sed -i "/qa/,/\}/ s/versionCode .*/versionCode = ${{github.event.number}} /" "app/build.gradle.kts" - sed -i "/qa/,/\}/ s/versionName .*/versionName = \"${{github.event.number}}\"/" "app/build.gradle.kts" + [ -e app/build.gradle ] && sed -i "/qa/,/\}/ s/versionCode .*/versionCode ${{github.event.number}} /" "app/build.gradle" + [ -e app/build.gradle ] && sed -i "/qa/,/\}/ s/versionName .*/versionName \"${{github.event.number}}\"/" "app/build.gradle" + [ -e app/build.gradle.kts ] && sed -i "/qa/,/\}/ s/versionCode .*/versionCode = ${{github.event.number}} /" "app/build.gradle.kts" + [ -e app/build.gradle.kts ] && sed -i "/qa/,/\}/ s/versionName .*/versionName = \"${{github.event.number}}\"/" "app/build.gradle.kts" ./gradlew assembleQaDebug $(find /usr/local/lib/android/sdk/build-tools/*/apksigner | sort | tail -n1) sign --ks-pass pass:"$KS_PASS" --key-pass pass:"$KEY_PASS" --ks-key-alias key0 --ks ".github/workflows/QA_keystore.jks" app/build/outputs/apk/qa/debug/*qa-debug*.apk .github/workflows/uploadArtifact.sh "$LOG_USERNAME" "$LOG_PASSWORD" "${{github.event.number}}" "${{github.event.number}}" "$GITHUB_TOKEN" From 19e8bfa2879ebc2f25984a988b1532760eb73a5f Mon Sep 17 00:00:00 2001 From: nextcloud-android-bot Date: Thu, 15 Jan 2026 17:30:19 +0000 Subject: [PATCH 054/125] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'.githu?= =?UTF-8?q?b/workflows/'=20with=20remote=20'config/workflows/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nextcloud-android-bot Signed-off-by: Raphael Vieira --- .github/workflows/analysis.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/qa.yml | 2 +- .github/workflows/renovate-approve-merge.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index d38ab46aec69..242d1db551e6 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -51,7 +51,7 @@ jobs: echo "repo=${{ github.event.pull_request.head.repo.full_name }}" } >> "$GITHUB_OUTPUT" fi - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false repository: ${{ steps.get-vars.outputs.repo }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 722e834664b7..b61cee53f5d6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,7 +34,7 @@ jobs: language: [ 'java' ] steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set Swap Space diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 26664a44cb78..21c6b202a11f 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -24,7 +24,7 @@ jobs: id: check-secrets - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ steps.check-secrets.outputs.ok == 'true' }} with: persist-credentials: false diff --git a/.github/workflows/renovate-approve-merge.yml b/.github/workflows/renovate-approve-merge.yml index 5551b18080b3..b92491bf3215 100644 --- a/.github/workflows/renovate-approve-merge.yml +++ b/.github/workflows/renovate-approve-merge.yml @@ -48,7 +48,7 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.head_ref }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 63e25ecd8b49..d3feeb822ac6 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -29,7 +29,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false From 0fffba7e78545e47008e3dc033fb0abc71a37c1f Mon Sep 17 00:00:00 2001 From: Nextcloud bot Date: Fri, 16 Jan 2026 03:01:17 +0000 Subject: [PATCH 055/125] fix(l10n): Update translations from Transifex Signed-off-by: Nextcloud bot Signed-off-by: Raphael Vieira --- app/src/main/res/values-de/strings.xml | 3 +++ app/src/main/res/values-gl/strings.xml | 3 +++ app/src/main/res/values-tr/strings.xml | 6 ++++++ app/src/main/res/values-uk/strings.xml | 6 ++++++ app/src/main/res/values-zh-rTW/strings.xml | 3 +++ 5 files changed, 21 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 402f2c2b1069..86e0fe430b2f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -962,6 +962,9 @@ Vorschlagen Synchronisieren Trotzdem synchronisieren + Konflikte lösen + Datei-Konflikte beim Hochladen erkannt. \"Uploads\" öffen, um sie aufzulösen. + Dateikonflikte beim Hochladen Konflikte gefunden Der Ordner %1$s existiert nicht mehr Synchronisierungsduplizierung diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index eeb1b07a788f..9f80ac0239b5 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -963,6 +963,9 @@ Suxerir Sincronizar Sincronizar de todos os xeitos + Resolver conflitos + Detectáronse conflitos de envío. Abra os envíos para resolvelos. + Conflitos no envío de ficheiros Atopáronse conflitos O cartafol %1$s xa non existe Duplicación de sincronización diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9a3ec361d629..345d1de6376b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -13,6 +13,7 @@ Gönder/paylaş Tablo görünümü Liste görünümü + İşlem tetiklendi Kişileri ve takvimi geri yükle Yeni klasör Taşı ya da kopyala @@ -396,6 +397,7 @@ Çakışma penceresi oluşturulamadı Dosya indirme yöneticisine aktarılamadı Dosya yazdırılamadı + İşlem başlatılamadı! Düzenleyici başlatılamadı Kullanıcı arayüzü güncellenemedi Sık kullanılanlara ekle @@ -755,6 +757,7 @@ Aralık İç klasörleri iki yönlü eşitleme ile yönetin İki yönlü eşitlemeyi aç + İki yönlü eşitleme Koyu Açık Sistem ayarı @@ -959,6 +962,9 @@ Öner Eşitle Yine de eşitle + Çakışmaları çözümle + Yükleme çakışmaları bulundu. Çözümlemek için yüklemeleri açın. + Dosya yükleme çakışmaları Çakışmalar bulundu %1$s klasörü artık yok Eşitleme çifti diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 63ac0c1239e3..1e1f6657e4ea 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -13,6 +13,7 @@ Надіслати або поділитися Показувати сіткою Показувати списком + Дію запущено Відновити контакти та календар Новий каталог Перемістити або копіювати @@ -396,6 +397,7 @@ Не вдалося створити вікно з інформацією про конфлікт Не вдалося передати файл до менеджера звантажень Не вдалося роздрукувати файл + Не вдалося виконати дію! Не вдалося відкрити редактор Неможливо оновити інтерфейс Додати зірочку @@ -755,6 +757,7 @@ Інтервал Керувати внутрішніми каталогами для двосторонньої синхронізації Увімкнути двосторонню синхронізацію + Двостороння синхронізація Темна Світла Системна @@ -959,6 +962,9 @@ Запропонувати Синхронізація Синхронізувати попри все + Вирішити конфлікти + Знайдено конфлікти при завантаженні. Відкрийте завантаження для вирішення. + Конфлікти при завантаженні файлів Конфліктів знайдено Каталог %1$s більше недоступний Синхронізувати із задвоєнням diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e75b8adcbb48..bb5bf971fcad 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -962,6 +962,9 @@ 建議 同步 仍要同步 + 解決衝突 + 偵測到上傳衝突。請開啟上傳檔案以解決問題。 + 檔案上傳衝突 出現衝突 資料夾 %1$s 已不存在 重複同步 From 9bd7766022010f7604da830abea9902dfa80b101 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 11 Jan 2026 17:48:02 +0100 Subject: [PATCH 056/125] Fixed GalleryAdapter#getFilesCount() The method actually returned the number of rows, but each row contains multiple files (currently: 2 or 5, depending on the display rotation). In the unit test, the expected file count was changed from the original and correct value 4 to 2 in the commit 66d8756b, but the correct change is in the implementation. Hence, reinstating the old expected value and the test does pass. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt | 2 +- .../java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt index f7f0bc2ef76b..edbbe0d4a19d 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -106,7 +106,7 @@ class GalleryAdapter( } private fun updateFilesCount() { - cachedFilesCount = files.fold(0) { acc, item -> acc + item.rows.size } + cachedFilesCount = files.sumOf { it.rows.sumOf { it.files.size } } } private fun rebuildFilePositionMap() { diff --git a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt index eca3acfef8ed..8f1446c330e3 100644 --- a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt +++ b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt @@ -95,6 +95,6 @@ class GalleryAdapterTest { sut.addFiles(list) - assertEquals(2, sut.getFilesCount()) + assertEquals(4, sut.getFilesCount()) } } From c41049bb7991132d6373bbc475c596c449800a16 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Sun, 11 Jan 2026 20:42:48 +0100 Subject: [PATCH 057/125] Test bugfix of #15918 and #16194: Gallery multiselect could crash The crash came from a collision of the improperly coded hash function GalleryRow#calculateHashCode(). Simply summing the row's file hashes can easily cause a collision as the same sum can also be achieved by two other file hashes. Take two rows with two files each. All having the same parentId=0. Row 1: 263512 and 148830 Row 2: 279897 and 132445 Summing the hashes given by calculateHashCode() will return in the same value for Row1 and Row2. PR #15918 fixed this by migrating away from this faulty hash function. This commit tests the bugfix first with a known collision and then some random fileIds for some additional explorative testing. This commit also removes the problematic calculateHashCode(), as it is now unused. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../owncloud/android/datamodel/GalleryRow.kt | 1 - .../android/ui/adapter/GalleryAdapterTest.kt | 129 +++++++++++++++++- 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt b/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt index febd3aaf19a4..6e99de382fae 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt +++ b/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt @@ -14,5 +14,4 @@ data class GalleryRow(val files: List, val defaultHeight: Int, val defau fun getMaxHeight(): Float = files.maxOfOrNull { OCFileUtils.getImageSize(it, defaultHeight.toFloat()).second.toFloat() } ?: 0f - fun calculateHashCode(): Long = files.sumOf { it.hashCode() }.toLong() } diff --git a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt index 8f1446c330e3..2b7bb193a56e 100644 --- a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt +++ b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt @@ -8,6 +8,7 @@ package com.owncloud.android.ui.adapter import android.content.Context +import android.text.TextUtils import com.nextcloud.client.account.User import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.preferences.AppPreferences @@ -18,14 +19,18 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.activity.ComponentsGetter import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface import com.owncloud.android.utils.theme.ViewThemeUtils -import junit.framework.Assert.assertEquals +import io.mockk.every +import io.mockk.mockkStatic import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.kotlin.doReturn import org.mockito.kotlin.whenever +import kotlin.random.Random class GalleryAdapterTest { @Mock @@ -55,20 +60,25 @@ class GalleryAdapterTest { private lateinit var mocks: AutoCloseable @Before - fun setUp() { + fun setUpMocks() { mocks = MockitoAnnotations.openMocks(this) + + whenever(transferServiceGetter.storageManager) doReturn storageManager + whenever(transferServiceGetter.fileUploaderHelper) doReturn fileUploadHelper + + // Mocking TextUtils so OCFile#existsOnDevice() doesn't fail due to Android not being available + // This is needed so OCFile#toString() works for logging errors + mockkStatic(TextUtils::class) + every { TextUtils.isEmpty(any()) } answers { arg(0)?.isEmpty() ?: true } } @After - fun tearDown() { + fun tearDownMocks() { mocks.close() } @Test fun testItemCount() { - whenever(transferServiceGetter.storageManager) doReturn storageManager - whenever(transferServiceGetter.fileUploaderHelper) doReturn fileUploadHelper - val thumbnailSize = 50 val sut = GalleryAdapter( @@ -97,4 +107,111 @@ class GalleryAdapterTest { assertEquals(4, sut.getFilesCount()) } + + @Test + @Suppress("LongMethod") + fun testIdUniqueness() { + val thumbnailSize = 50 + + val sut = GalleryAdapter( + context, + user, + ocFileListFragmentInterface, + preferences, + transferServiceGetter, + viewThemeUtils, + 5, + thumbnailSize + ) + val rows = mutableListOf() + + // Test a known (former) hash collision + val row1File1 = 263512L + val row1File2 = 148830L + val row2File1 = 279897L + val row2File2 = 132445L + rows.add( + GalleryRow( + listOf( + OCFile("/$row1File1.md").apply { + fileId = row1File1 + parentId = 0 + }, + OCFile("/$row1File2.md").apply { + fileId = row1File2 + parentId = 0 + } + ), + thumbnailSize, + thumbnailSize + ) + ) + rows.add( + GalleryRow( + listOf( + OCFile("/$row2File1.md").apply { + fileId = row2File1 + parentId = 0 + }, + OCFile("/$row2File2.md").apply { + fileId = row2File2 + parentId = 0 + } + ), + thumbnailSize, + thumbnailSize + ) + ) + val alreadyUsedFileIds = listOf(row1File1, row1File2, row2File1, row2File2) + + // Generate some random Ids for some explorative testing + val randomFileIds = uniquePositiveRandomLongs(10000, 1000000) + for (i in 0..() + for (i in 0.. { + require(count >= 0) { "count must be non-negative" } + if (count == 0) return emptyList() + + val set = HashSet(count) + while (set.size < count) { + // produce positive (> 0) values + set.add(Random.nextLong(1, max)) + } + return set.toList() + } } From fb3a19988c52542fa9423c36c47d146b5b746b24 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Fri, 2 Jan 2026 18:12:20 +0100 Subject: [PATCH 058/125] UI test for multi selection in media gallery This was originally meant to find the cause of crashes caused by non-unique IDs in the ViewHolder (#15918 and #16194). But the UI test still succeeded because it didn't carefully craft the fileIds to cause the issue. Once the actual reason, a collision of an improper hash function, was found, a unit test for that scenario was added instead - see prior commit. Committing this UI test anyways, as it might be able to catch other, future regressions. Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../android/ui/fragment/GalleryFragmentIT.kt | 105 +++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt index 0bdf133e055f..3b4ede5f274b 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Philipp Hasper * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2022 Tobias Kaminsky * SPDX-FileCopyrightText: 2022 Nextcloud GmbH @@ -12,24 +13,37 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId import com.nextcloud.test.TestActivity import com.owncloud.android.AbstractIT +import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.datamodel.ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.files.model.ImageDimension +import com.owncloud.android.ui.adapter.GalleryRowHolder import com.owncloud.android.utils.ScreenshotTest import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Before import org.junit.Test import java.util.Random +import org.hamcrest.Matchers.`is` as isSameView class GalleryFragmentIT : AbstractIT() { private val testClassName = "com.owncloud.android.ui.fragment.GalleryFragmentIT" @@ -85,14 +99,99 @@ class GalleryFragmentIT : AbstractIT() { } } - private fun createImage(id: Int, width: Int? = null, height: Int? = null) { + @Test + fun multiSelect() { + val imageCount = 100 + for (num in 1..imageCount) { + // Spread the files over multiple days to also get multiple sections + val secondsPerDay = 1L * 24 * 60 * 60 + createImage(10000000 + num * 7 * secondsPerDay, 700, 300) + } + + // Test that scrolling through the whole list is possible without a crash + launchActivity().use { scenario -> + lateinit var galleryFragment: GalleryFragment + scenario.onActivity { testActivity -> + galleryFragment = GalleryFragment() + testActivity.addFragment(galleryFragment) + } + onView(isRoot()).check(matches(isDisplayed())) + + onView(withId(R.id.list_root)) + .perform(RecyclerViewActions.scrollToLastPosition()) + .perform(RecyclerViewActions.scrollToPosition(0)) + } + + // Test selection of all entries + launchActivity().use { scenario -> + lateinit var galleryFragment: GalleryFragment + scenario.onActivity { testActivity -> + galleryFragment = GalleryFragment() + testActivity.addFragment(galleryFragment) + } + onView(isRoot()).check(matches(isDisplayed())) + + // get the RecyclerView and itemCount on the UI thread + val recyclerView = findRecyclerViewRecursively(galleryFragment.view) + ?: throw AssertionError("RecyclerView not found") + val adapterCount = recyclerView.adapter?.itemCount ?: 0 + + // Perform the view action on each adapter position (row) + for (pos in 0 until adapterCount) { + onView(isSameView(recyclerView)) + .perform(actionOnItemAtPosition(pos, longClickAllThumbnailsInRow())) + } + + val checked = galleryFragment.commonAdapter.getCheckedItems() + assertEquals(imageCount, checked.size) + } + } + + /** Recursively walk view tree to find the first RecyclerView. Runs on the same thread that calls it. */ + @Suppress("ReturnCount") + private fun findRecyclerViewRecursively(root: View?): RecyclerView? { + if (root == null) return null + if (root is RecyclerView) return root + if (root !is ViewGroup) return null + for (i in 0 until root.childCount) { + val child = root.getChildAt(i) + val found = findRecyclerViewRecursively(child) + if (found != null) return found + } + return null + } + + /** For the given row view, long-click each thumbnail inside its FrameLayouts */ + @Suppress("NestedBlockDepth") + fun longClickAllThumbnailsInRow() = object : ViewAction { + override fun getConstraints() = isDisplayed() + + override fun getDescription() = "Long-click all thumbnail ImageViews inside a GalleryRowHolder" + + override fun perform(uiController: UiController, view: View) { + if (view is ViewGroup) { + // each child of the row is a FrameLayout representing one gallery cell + for (i in 0 until view.childCount) { + val cell = view.getChildAt(i) + if (cell is FrameLayout) { + // GalleryRowHolder builds FrameLayout with children: + // 0 = shimmer, 1 = thumbnail ImageView, 2 = checkbox + val thumbnail = if (cell.childCount > 1) cell.getChildAt(1) else cell + thumbnail.performLongClick() + } + } + } + } + } + + private fun createImage(id: Long, width: Int? = null, height: Int? = null) { val defaultSize = ThumbnailsCacheManager.getThumbnailDimension().toFloat() val file = OCFile("/$id.png").apply { - fileId = id.toLong() + fileId = id remoteId = "$id" mimeType = "image/png" isPreviewAvailable = true - modificationTimestamp = (1658475504 + id.toLong()) * 1000 + modificationTimestamp = (1658475504 + id) * 1000 imageDimension = ImageDimension(width?.toFloat() ?: defaultSize, height?.toFloat() ?: defaultSize) storageManager.saveFile(this) } From be59b9186330b62c5c976f9535c60c674d4190fe Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Thu, 15 Jan 2026 07:48:13 +0100 Subject: [PATCH 059/125] ThumbnailsCacheManager: fixed mutex and volatile ThumbnailsCacheManager#clearCache() needs to mutex the access, otherwise there is a race condition with initDiskCacheAsync(), as observed in a small fraction of GalleryFragmentIT tests Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .../android/datamodel/ThumbnailsCacheManager.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index 08250553fd6c..2213a77b7439 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -100,8 +100,8 @@ public final class ThumbnailsCacheManager { private static final String ETAG = "ETag"; private static final Object mThumbnailsDiskCacheLock = new Object(); - private static DiskLruImageCache mThumbnailCache; - private static boolean mThumbnailCacheStarting = true; + private static volatile DiskLruImageCache mThumbnailCache; + private static volatile boolean mThumbnailCacheStarting = true; private static final int DISK_CACHE_SIZE = 1024 * 1024 * 200; // 200MB private static final CompressFormat mCompressFormat = CompressFormat.JPEG; @@ -1243,8 +1243,12 @@ public static void generateThumbnailFromOCFile(OCFile file, User user, Context c @VisibleForTesting public static void clearCache() { - mThumbnailCache.clearCache(); - mThumbnailCache = null; + synchronized (mThumbnailsDiskCacheLock) { + if (mThumbnailCache != null) { + mThumbnailCache.clearCache(); + mThumbnailCache = null; + } + } } public static void setClient(OwnCloudClient client) { From 84d4d64fbff85f05f891f198a4ac42974c0d35d2 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Thu, 15 Jan 2026 07:50:00 +0100 Subject: [PATCH 060/125] Added LongLine as linter warning The CI will fail the build in case of long lines, so at least warn the user before. As the CI sees this as error, we might also set this as error in the IDE, but for now let's be conservative and only show a warning Signed-off-by: Philipp Hasper Signed-off-by: Raphael Vieira --- .idea/inspectionProfiles/ktlint.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/.idea/inspectionProfiles/ktlint.xml b/.idea/inspectionProfiles/ktlint.xml index 4aab2f7215ef..631c09bc2163 100644 --- a/.idea/inspectionProfiles/ktlint.xml +++ b/.idea/inspectionProfiles/ktlint.xml @@ -33,6 +33,7 @@ + + + + + + + + From d827009da8019dcf414158e4398d370efc3fdcf3 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Tue, 20 Jan 2026 00:12:52 +0000 Subject: [PATCH 076/125] ran spotless apply Signed-off-by: Raphael Vieira --- .../client/jobs/upload/FileUploadWorkerIT.kt | 72 +++ .../com/nextcloud/client/jobs/JobsModule.kt | 15 +- .../client/jobs/upload/FileUploadHelper.kt | 6 +- .../jobs/upload/FileUploadOperationFactory.kt | 97 ++-- .../client/jobs/upload/FileUploadWorker.kt | 6 +- .../jobs/upload/FileUploadWorkerTest.kt | 469 +++++++++--------- 6 files changed, 367 insertions(+), 298 deletions(-) create mode 100644 app/src/androidTest/java/com/nextcloud/client/jobs/upload/FileUploadWorkerIT.kt diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/upload/FileUploadWorkerIT.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/upload/FileUploadWorkerIT.kt new file mode 100644 index 000000000000..67e8d7a4a494 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/upload/FileUploadWorkerIT.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.jobs.upload + +import androidx.work.WorkManager +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.operations.UploadFileOperation +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FileUploadWorkerIT : AbstractOnServerIT() { + + private lateinit var workManager: WorkManager + private lateinit var fileUploadHelper: FileUploadHelper + + @Before + fun setUp() { + workManager = WorkManager.getInstance(targetContext) + fileUploadHelper = FileUploadHelper.instance() + } + + @Test + fun multipleFilesUploadBatch() { + val file1 = getDummyFile("empty.txt") + val file2 = getDummyFile("nonEmpty.txt") + val remotePath1 = "/batch_upload_1_${System.currentTimeMillis()}.txt" + val remotePath2 = "/batch_upload_2_${System.currentTimeMillis()}.txt" + + fileUploadHelper.uploadNewFiles( + user, + arrayOf(file1.absolutePath, file2.absolutePath), + arrayOf(remotePath1, remotePath2), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + true, + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.DEFAULT + ) + + // waiting for the upload jobs to finish + val tag = "files_upload" + user.accountName + var isFinished = false + val startTime = System.currentTimeMillis() + val timeout = 60000L + + while (!isFinished && System.currentTimeMillis() - startTime < timeout) { + val workInfos = workManager.getWorkInfosByTag(tag).get() + if (workInfos.isNotEmpty() && workInfos.all { it.state.isFinished }) { + isFinished = true + } else { + Thread.sleep(1000) + } + } + + assertTrue("Batch upload jobs did not finish within timeout", isFinished) + + // Verifying both files are uploaded to the server + val result1 = ReadFileRemoteOperation(remotePath1).execute(client) + assertTrue("File 1 should be on server", result1.isSuccess) + + val result2 = ReadFileRemoteOperation(remotePath2).execute(client) + assertTrue("File 2 should be on server", result2.isSuccess) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt b/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt index 9b30ffb372f3..b3c3406f4ffc 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt @@ -53,13 +53,10 @@ class JobsModule { connectivityService: ConnectivityService, powerManagementService: PowerManagementService, context: Context - ): FileUploadOperationFactory { - return FileUploadOperationFactoryImpl( - uploadsStorageManager, - connectivityService, - powerManagementService, - context - ) - } - + ): FileUploadOperationFactory = FileUploadOperationFactoryImpl( + uploadsStorageManager, + connectivityService, + powerManagementService, + context + ) } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 9409143d5b92..5ec404cf3568 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -376,8 +376,10 @@ class FileUploadHelper { return activeUploadFileOperations.values.any { operation -> operation.user?.accountName == upload.accountName && - (upload.remotePath == operation.remotePath || - upload.remotePath == operation.oldFile?.remotePath) + ( + upload.remotePath == operation.remotePath || + upload.remotePath == operation.oldFile?.remotePath + ) } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadOperationFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadOperationFactory.kt index c03234bea4cf..ecd5ac033590 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadOperationFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadOperationFactory.kt @@ -1,52 +1,45 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.nextcloud.client.jobs.upload - -import android.content.Context -import com.nextcloud.client.account.User -import com.nextcloud.client.device.PowerManagementService -import com.nextcloud.client.network.ConnectivityService -import com.owncloud.android.datamodel.FileDataStorageManager -import com.owncloud.android.datamodel.UploadsStorageManager -import com.owncloud.android.db.OCUpload -import com.owncloud.android.operations.UploadFileOperation -import javax.inject.Inject - -interface FileUploadOperationFactory { - fun create( - upload: OCUpload, - user: User, - storageManager: FileDataStorageManager - ): UploadFileOperation -} - -class FileUploadOperationFactoryImpl @Inject constructor( - private val uploadsStorageManager: UploadsStorageManager, - private val connectivityService: ConnectivityService, - private val powerManagementService: PowerManagementService, - private val context: Context -) : FileUploadOperationFactory { - override fun create( - upload: OCUpload, - user: User, - storageManager: FileDataStorageManager - ): UploadFileOperation = UploadFileOperation( - uploadsStorageManager, - connectivityService, - powerManagementService, - user, - null, - upload, - upload.nameCollisionPolicy, - upload.localAction, - context, - upload.isUseWifiOnly, - upload.isWhileChargingOnly, - storageManager - ) -} +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.jobs.upload + +import android.content.Context +import com.nextcloud.client.account.User +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.network.ConnectivityService +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.operations.UploadFileOperation +import javax.inject.Inject + +interface FileUploadOperationFactory { + fun create(upload: OCUpload, user: User, storageManager: FileDataStorageManager): UploadFileOperation +} + +class FileUploadOperationFactoryImpl @Inject constructor( + private val uploadsStorageManager: UploadsStorageManager, + private val connectivityService: ConnectivityService, + private val powerManagementService: PowerManagementService, + private val context: Context +) : FileUploadOperationFactory { + override fun create(upload: OCUpload, user: User, storageManager: FileDataStorageManager): UploadFileOperation = + UploadFileOperation( + uploadsStorageManager, + connectivityService, + powerManagementService, + user, + null, + upload, + upload.nameCollisionPolicy, + upload.localAction, + context, + upload.isUseWifiOnly, + upload.isWhileChargingOnly, + storageManager + ) +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 52afe6b768c4..2faf6988927c 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -120,10 +120,8 @@ class FileUploadWorker( onCompleted() } - fun isUploading(remotePath: String?, accountName: String?): Boolean { - return activeUploadFileOperations.values.any { - it.remotePath == remotePath && it.user.accountName == accountName - } + fun isUploading(remotePath: String?, accountName: String?): Boolean = activeUploadFileOperations.values.any { + it.remotePath == remotePath && it.user.accountName == accountName } fun getUploadAction(action: String): Int = when (action) { diff --git a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt index 053643024656..63723a216e6a 100644 --- a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt +++ b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt @@ -1,231 +1,238 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.nextcloud.client.jobs.upload - -import android.app.NotificationManager -import android.content.Context -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.work.ListenableWorker -import androidx.work.WorkerParameters -import com.nextcloud.android.common.ui.theme.MaterialSchemes -import com.nextcloud.client.account.User -import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.device.PowerManagementService -import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.client.network.Connectivity -import com.nextcloud.client.network.ConnectivityService -import com.nextcloud.client.preferences.AppPreferences -import com.owncloud.android.datamodel.UploadsStorageManager -import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode -import com.owncloud.android.operations.UploadFileOperation -import com.owncloud.android.utils.theme.ViewThemeUtils -import io.mockk.every -import io.mockk.mockk -import io.mockk.unmockkAll -import io.mockk.verify -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.util.Optional - -class FileUploadWorkerTest { - - private lateinit var worker: FileUploadWorker - private val uploadsStorageManager: UploadsStorageManager = mockk(relaxed = true) - private val connectivityService: ConnectivityService = mockk(relaxed = true) - private val powerManagementService: PowerManagementService = mockk(relaxed = true) - private val userAccountManager: UserAccountManager = mockk(relaxed = true) - private val localBroadcastManager: LocalBroadcastManager = mockk(relaxed = true) - private val backgroundJobManager: BackgroundJobManager = mockk(relaxed = true) - private val preferences: AppPreferences = mockk(relaxed = true) - private val clientFactory: ClientFactory = mockk(relaxed = true) - private val uploadFileOperationFactory: FileUploadOperationFactory = mockk(relaxed = true) - private val context: Context = mockk(relaxed = true) - private val params: WorkerParameters = mockk(relaxed = true) - private val systemNotificationManager: NotificationManager = mockk(relaxed = true) - private val uploadNotificationManager: UploadNotificationManager = mockk(relaxed = true) - - @Before - fun setUp() { - every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns systemNotificationManager - - val materialSchemes = mockk(relaxed = true) - val viewThemeUtils = ViewThemeUtils(materialSchemes, mockk(relaxed = true)) - - val connectivity = mockk() - every { connectivity.isConnected } returns true - every { connectivityService.getConnectivity() } returns connectivity - every { connectivityService.isConnected } returns true - every { connectivityService.isInternetWalled } returns false - - worker = FileUploadWorker( - uploadsStorageManager, - connectivityService, - powerManagementService, - userAccountManager, - viewThemeUtils, - localBroadcastManager, - backgroundJobManager, - preferences, - clientFactory, - uploadFileOperationFactory, - context, - uploadNotificationManager, - params - ) - } - - @After - fun tearDown() { - unmockkAll() - FileUploadWorker.activeUploadFileOperations.clear() - } - - @Test - fun `doWork returns failure when account name is missing`() = runBlocking { - // GIVEN - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns null - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.failure(), result) - } - - @Test - fun `doWork returns failure when upload ids are missing`() = runBlocking { - // GIVEN - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" - every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns null - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.failure(), result) - } - - @Test - fun `doWork returns failure when batch index is missing`() = runBlocking { - // GIVEN - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" - every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) - every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1) } returns -1 - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.failure(), result) - } - - @Test - fun `doWork returns failure when user is not found`() = runBlocking { - // GIVEN - val accountName = "account" - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns accountName - every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) - every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, any()) } returns 0 - every { params.inputData.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, any()) } returns 1 - every { userAccountManager.getUser(accountName) } returns Optional.empty() - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.failure(), result) - } - - @Test - fun `doWork returns success when there are no uploads`() = runBlocking { - // GIVEN - val accountName = "account" - val user = mockk(relaxed = true) - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns accountName - every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) - every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, any()) } returns 0 - every { params.inputData.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, any()) } returns 1 - every { userAccountManager.getUser(accountName) } returns Optional.of(user) - every { uploadsStorageManager.getUploadsByIds(any(), accountName) } returns emptyList() - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.success(), result) - } - - - @Test - fun `onTransferProgress updates notification manager`() { - // GIVEN - val fileName = "testFile" - val operation = mockk(relaxed = true) - FileUploadWorker.activeUploadFileOperations[fileName] = operation - - // WHEN - worker.onTransferProgress(100, 50, 100, fileName) - - // THEN - verify { uploadNotificationManager.updateUploadProgress(50, operation) } - } - - @Test - fun `cancelCurrentUpload cancels matching operations`() { - // GIVEN - val remotePath = "path" - val accountName = "account" - val operation = mockk(relaxed = true) - every { operation.remotePath } returns remotePath - every { operation.user.accountName } returns accountName - FileUploadWorker.activeUploadFileOperations["key"] = operation - - // WHEN - var completed = false - FileUploadWorker.cancelCurrentUpload(remotePath, accountName) { - completed = true - } - - // THEN - verify { operation.cancel(ResultCode.USER_CANCELLED) } - assertTrue(completed) - } - - @Test - fun `isUploading returns true when operation exists`() { - // GIVEN - val remotePath = "path" - val accountName = "account" - val operation = mockk(relaxed = true) - every { operation.remotePath } returns remotePath - every { operation.user.accountName } returns accountName - FileUploadWorker.activeUploadFileOperations["key"] = operation - - // WHEN & THEN - assertTrue(FileUploadWorker.isUploading(remotePath, accountName)) - assertFalse(FileUploadWorker.isUploading("other", accountName)) - } - - @Test - fun `getUploadAction returns correct values`() { - assertEquals(FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, - FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_FORGET")) - assertEquals(FileUploadWorker.LOCAL_BEHAVIOUR_MOVE, - FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_MOVE")) - assertEquals(FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, - FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_DELETE")) - assertEquals(FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, - FileUploadWorker.getUploadAction("UNKNOWN")) - } -} +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.jobs.upload + +import android.app.NotificationManager +import android.content.Context +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.nextcloud.android.common.ui.theme.MaterialSchemes +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.Connectivity +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.theme.ViewThemeUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Optional + +class FileUploadWorkerTest { + + private lateinit var worker: FileUploadWorker + private val uploadsStorageManager: UploadsStorageManager = mockk(relaxed = true) + private val connectivityService: ConnectivityService = mockk(relaxed = true) + private val powerManagementService: PowerManagementService = mockk(relaxed = true) + private val userAccountManager: UserAccountManager = mockk(relaxed = true) + private val localBroadcastManager: LocalBroadcastManager = mockk(relaxed = true) + private val backgroundJobManager: BackgroundJobManager = mockk(relaxed = true) + private val preferences: AppPreferences = mockk(relaxed = true) + private val clientFactory: ClientFactory = mockk(relaxed = true) + private val uploadFileOperationFactory: FileUploadOperationFactory = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val params: WorkerParameters = mockk(relaxed = true) + private val systemNotificationManager: NotificationManager = mockk(relaxed = true) + private val uploadNotificationManager: UploadNotificationManager = mockk(relaxed = true) + + @Before + fun setUp() { + every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns systemNotificationManager + + val materialSchemes = mockk(relaxed = true) + val viewThemeUtils = ViewThemeUtils(materialSchemes, mockk(relaxed = true)) + + val connectivity = mockk() + every { connectivity.isConnected } returns true + every { connectivityService.getConnectivity() } returns connectivity + every { connectivityService.isConnected } returns true + every { connectivityService.isInternetWalled } returns false + + worker = FileUploadWorker( + uploadsStorageManager, + connectivityService, + powerManagementService, + userAccountManager, + viewThemeUtils, + localBroadcastManager, + backgroundJobManager, + preferences, + clientFactory, + uploadFileOperationFactory, + context, + uploadNotificationManager, + params + ) + } + + @After + fun tearDown() { + unmockkAll() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @Test + fun `doWork returns failure when account name is missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns null + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when upload ids are missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns null + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when batch index is missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) + every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1) } returns -1 + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when user is not found`() = runBlocking { + // GIVEN + val accountName = "account" + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns accountName + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) + every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, any()) } returns 0 + every { params.inputData.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, any()) } returns 1 + every { userAccountManager.getUser(accountName) } returns Optional.empty() + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns success when there are no uploads`() = runBlocking { + // GIVEN + val accountName = "account" + val user = mockk(relaxed = true) + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns accountName + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) + every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, any()) } returns 0 + every { params.inputData.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, any()) } returns 1 + every { userAccountManager.getUser(accountName) } returns Optional.of(user) + every { uploadsStorageManager.getUploadsByIds(any(), accountName) } returns emptyList() + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.success(), result) + } + + @Test + fun `onTransferProgress updates notification manager`() { + // GIVEN + val fileName = "testFile" + val operation = mockk(relaxed = true) + FileUploadWorker.activeUploadFileOperations[fileName] = operation + + // WHEN + worker.onTransferProgress(100, 50, 100, fileName) + + // THEN + verify { uploadNotificationManager.updateUploadProgress(50, operation) } + } + + @Test + fun `cancelCurrentUpload cancels matching operations`() { + // GIVEN + val remotePath = "path" + val accountName = "account" + val operation = mockk(relaxed = true) + every { operation.remotePath } returns remotePath + every { operation.user.accountName } returns accountName + FileUploadWorker.activeUploadFileOperations["key"] = operation + + // WHEN + var completed = false + FileUploadWorker.cancelCurrentUpload(remotePath, accountName) { + completed = true + } + + // THEN + verify { operation.cancel(ResultCode.USER_CANCELLED) } + assertTrue(completed) + } + + @Test + fun `isUploading returns true when operation exists`() { + // GIVEN + val remotePath = "path" + val accountName = "account" + val operation = mockk(relaxed = true) + every { operation.remotePath } returns remotePath + every { operation.user.accountName } returns accountName + FileUploadWorker.activeUploadFileOperations["key"] = operation + + // WHEN & THEN + assertTrue(FileUploadWorker.isUploading(remotePath, accountName)) + assertFalse(FileUploadWorker.isUploading("other", accountName)) + } + + @Test + fun `getUploadAction returns correct values`() { + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_FORGET") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_MOVE, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_MOVE") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_DELETE") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + FileUploadWorker.getUploadAction("UNKNOWN") + ) + } +} From cc955020d82988ed23dc71e35c820e068b9a63d3 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Tue, 20 Jan 2026 21:59:54 +0000 Subject: [PATCH 077/125] Explorting alternative solution - parallelising workers - tests added Signed-off-by: Raphael Vieira --- .../client/jobs/BackgroundJobManagerTest.kt | 71 +++++- .../client/jobs/upload/FileUploadWorkerIT.kt | 72 ------ .../client/jobs/BackgroundJobFactory.kt | 9 - .../client/jobs/BackgroundJobManagerImpl.kt | 42 ++-- .../com/nextcloud/client/jobs/JobsModule.kt | 18 -- .../client/jobs/upload/FileUploadHelper.kt | 23 +- .../jobs/upload/FileUploadOperationFactory.kt | 45 ---- .../client/jobs/upload/FileUploadWorker.kt | 214 ++++++---------- .../client/network/ClientFactoryImpl.java | 3 - .../preferences/AppPreferencesImpl.java | 2 +- .../client/jobs/BackgroundJobFactoryTest.kt | 10 - .../jobs/upload/FileUploadWorkerTest.kt | 238 ------------------ 12 files changed, 184 insertions(+), 563 deletions(-) delete mode 100644 app/src/androidTest/java/com/nextcloud/client/jobs/upload/FileUploadWorkerIT.kt delete mode 100644 app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadOperationFactory.kt delete mode 100644 app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt index d59256f786c0..9551f69ad5ae 100644 --- a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt @@ -16,10 +16,13 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest +import androidx.work.WorkContinuation import androidx.work.WorkInfo import androidx.work.WorkManager import com.nextcloud.client.account.User import com.nextcloud.client.core.Clock +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.utils.extensions.toByteArray import com.owncloud.android.lib.common.utils.Log_OC import org.apache.commons.io.FileUtils @@ -42,6 +45,7 @@ import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.io.File @@ -64,6 +68,7 @@ import java.util.concurrent.TimeoutException BackgroundJobManagerTest.PeriodicContactsBackup::class, BackgroundJobManagerTest.ImmediateContactsBackup::class, BackgroundJobManagerTest.ImmediateContactsImport::class, + BackgroundJobManagerTest.FilesUpload::class, BackgroundJobManagerTest.Tags::class ) class BackgroundJobManagerTest { @@ -90,6 +95,7 @@ class BackgroundJobManagerTest { internal lateinit var user: User internal lateinit var workManager: WorkManager internal lateinit var clock: Clock + internal lateinit var preferences: AppPreferences internal lateinit var backgroundJobManager: BackgroundJobManagerImpl internal lateinit var context: Context @@ -100,9 +106,10 @@ class BackgroundJobManagerTest { whenever(user.accountName).thenReturn(USER_ACCOUNT_NAME) workManager = mock() clock = mock() + preferences = mock() whenever(clock.currentTime).thenReturn(TIMESTAMP) whenever(clock.currentDate).thenReturn(Date(TIMESTAMP)) - backgroundJobManager = BackgroundJobManagerImpl(workManager, clock, mock()) + backgroundJobManager = BackgroundJobManagerImpl(workManager, clock, preferences) } fun assertHasRequiredTags(tags: Set, jobName: String, user: User? = null) { @@ -385,6 +392,68 @@ class BackgroundJobManagerTest { } } + class FilesUpload : Fixture() { + + @Test + fun start_files_upload_job_enqueues_batches() { + + val uploadIds = longArrayOf(1, 2, 3, 4, 5) + whenever(preferences.maxConcurrentUploads).thenReturn(2) + + val continuation: WorkContinuation = mock() + whenever(workManager.beginUniqueWork(any(), any(), any>())).thenReturn(continuation) + + + backgroundJobManager.startFilesUploadJob(user, uploadIds, true) + + + val tagCaptor = argumentCaptor() + val requestsCaptor = argumentCaptor>() + + verify(workManager, timeout(1000)).beginUniqueWork( + tagCaptor.capture(), + eq(ExistingWorkPolicy.KEEP), + requestsCaptor.capture() + ) + + val tag = tagCaptor.firstValue + assertTrue(tag.startsWith(BackgroundJobManagerImpl.JOB_FILES_UPLOAD + USER_ACCOUNT_NAME + "_")) + + val requests = requestsCaptor.firstValue + assertEquals(3, requests.size) + + // Check first batch [1, 2] + val data1 = requests[0].workSpec.input + assertEquals(true, data1.getBoolean(FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false)) + assertEquals(USER_ACCOUNT_NAME, data1.getString(FileUploadWorker.ACCOUNT)) + assertEquals(5, data1.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, 0)) + assertTrue(longArrayOf(1, 2).contentEquals(data1.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) + assertEquals(0, data1.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1)) + + // Check second batch [3, 4] + val data2 = requests[1].workSpec.input + assertTrue(longArrayOf(3, 4).contentEquals(data2.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) + assertEquals(1, data2.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1)) + + // Check third batch [5] + val data3 = requests[2].workSpec.input + assertTrue(longArrayOf(5).contentEquals(data3.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) + assertEquals(2, data3.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1)) + + verify(continuation).enqueue() + } + + @Test + fun start_files_upload_job_does_nothing_when_empty() { + val uploadIds = longArrayOf() + whenever(preferences.maxConcurrentUploads).thenReturn(2) + + backgroundJobManager.startFilesUploadJob(user, uploadIds, true) + + verify(workManager, timeout(1000).times(0)).beginUniqueWork(any(), any(), any>()) + } + } + class Tags { @Test fun split_tag_key_and_value() { diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/upload/FileUploadWorkerIT.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/upload/FileUploadWorkerIT.kt deleted file mode 100644 index 67e8d7a4a494..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/jobs/upload/FileUploadWorkerIT.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.jobs.upload - -import androidx.work.WorkManager -import com.owncloud.android.AbstractOnServerIT -import com.owncloud.android.files.services.NameCollisionPolicy -import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation -import com.owncloud.android.operations.UploadFileOperation -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class FileUploadWorkerIT : AbstractOnServerIT() { - - private lateinit var workManager: WorkManager - private lateinit var fileUploadHelper: FileUploadHelper - - @Before - fun setUp() { - workManager = WorkManager.getInstance(targetContext) - fileUploadHelper = FileUploadHelper.instance() - } - - @Test - fun multipleFilesUploadBatch() { - val file1 = getDummyFile("empty.txt") - val file2 = getDummyFile("nonEmpty.txt") - val remotePath1 = "/batch_upload_1_${System.currentTimeMillis()}.txt" - val remotePath2 = "/batch_upload_2_${System.currentTimeMillis()}.txt" - - fileUploadHelper.uploadNewFiles( - user, - arrayOf(file1.absolutePath, file2.absolutePath), - arrayOf(remotePath1, remotePath2), - FileUploadWorker.LOCAL_BEHAVIOUR_COPY, - true, - UploadFileOperation.CREATED_BY_USER, - false, - false, - NameCollisionPolicy.DEFAULT - ) - - // waiting for the upload jobs to finish - val tag = "files_upload" + user.accountName - var isFinished = false - val startTime = System.currentTimeMillis() - val timeout = 60000L - - while (!isFinished && System.currentTimeMillis() - startTime < timeout) { - val workInfos = workManager.getWorkInfosByTag(tag).get() - if (workInfos.isNotEmpty() && workInfos.all { it.state.isFinished }) { - isFinished = true - } else { - Thread.sleep(1000) - } - } - - assertTrue("Batch upload jobs did not finish within timeout", isFinished) - - // Verifying both files are uploaded to the server - val result1 = ReadFileRemoteOperation(remotePath1).execute(client) - assertTrue("File 1 should be on server", result1.isSuccess) - - val result2 = ReadFileRemoteOperation(remotePath2).execute(client) - assertTrue("File 2 should be on server", result2.isSuccess) - } -} diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 19ba8afb79ba..3e67df51fa92 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -28,11 +28,8 @@ 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.FileUploadOperationFactory import com.nextcloud.client.jobs.upload.FileUploadWorker -import com.nextcloud.client.jobs.upload.UploadNotificationManager import com.nextcloud.client.logger.Logger -import com.nextcloud.client.network.ClientFactory import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.datamodel.ArbitraryDataProvider @@ -42,7 +39,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import org.greenrobot.eventbus.EventBus import javax.inject.Inject import javax.inject.Provider -import kotlin.random.Random /** * This factory is responsible for creating all background jobs and for injecting worker dependencies. @@ -69,8 +65,6 @@ class BackgroundJobFactory @Inject constructor( private val localBroadcastManager: Provider, private val generatePdfUseCase: GeneratePDFUseCase, private val syncedFolderProvider: SyncedFolderProvider, - private val clientFactory: ClientFactory, - private val fileUploadOperationFactory: FileUploadOperationFactory, private val database: NextcloudDatabase ) : WorkerFactory() { @@ -243,10 +237,7 @@ class BackgroundJobFactory @Inject constructor( localBroadcastManager.get(), backgroundJobManager.get(), preferences, - clientFactory, - fileUploadOperationFactory, context, - UploadNotificationManager(context, viewThemeUtils.get(), Random.nextInt()), params ) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 0ad01e66c7ad..a75abe464616 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -46,6 +46,7 @@ import kotlinx.coroutines.launch import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit +import kotlin.math.round import kotlin.reflect.KClass /** @@ -657,48 +658,41 @@ internal class BackgroundJobManagerImpl( */ override fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) { defaultDispatcherScope.launch { - val batchSize = FileUploadHelper.MAX_FILE_COUNT - val batches = uploadIds.toList().chunked(batchSize) - val tag = startFileUploadJobTag(user.accountName) + val chunkSize = (uploadIds.size / preferences.maxConcurrentUploads).coerceAtLeast(1) + val batches = uploadIds.toList().chunked(chunkSize) + val executionId = System.currentTimeMillis() + val tag = "${startFileUploadJobTag(user.accountName)}_$executionId" val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() - val dataBuilder = Data.Builder() - .putBoolean( - FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, - showSameFileAlreadyExistsNotification - ) - .putString(FileUploadWorker.ACCOUNT, user.accountName) - .putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, uploadIds.size) - val workRequests = batches.mapIndexed { index, batch -> - dataBuilder + val data = Data.Builder() + .putBoolean( + FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, + showSameFileAlreadyExistsNotification + ) + .putString(FileUploadWorker.ACCOUNT, user.accountName) + .putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, uploadIds.size) .putLongArray(FileUploadWorker.UPLOAD_IDS, batch.toLongArray()) .putInt(FileUploadWorker.CURRENT_BATCH_INDEX, index) + .build() oneTimeRequestBuilder(FileUploadWorker::class, JOB_FILES_UPLOAD, user) .addTag(tag) - .setInputData(dataBuilder.build()) + .setInputData(data) .setConstraints(constraints) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() } - // Chain the work requests sequentially if (workRequests.isNotEmpty()) { - var workChain = workManager.beginUniqueWork( + workManager.beginUniqueWork( tag, - ExistingWorkPolicy.APPEND_OR_REPLACE, - workRequests.first() - ) - - workRequests.drop(1).forEach { request -> - workChain = workChain.then(request) - } - - workChain.enqueue() + ExistingWorkPolicy.KEEP, + workRequests + ).enqueue() } } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt b/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt index b3c3406f4ffc..3f331453c7a1 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt @@ -11,12 +11,7 @@ import android.content.ContextWrapper import androidx.work.Configuration import androidx.work.WorkManager import com.nextcloud.client.core.Clock -import com.nextcloud.client.device.PowerManagementService -import com.nextcloud.client.jobs.upload.FileUploadOperationFactory -import com.nextcloud.client.jobs.upload.FileUploadOperationFactoryImpl -import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences -import com.owncloud.android.datamodel.UploadsStorageManager import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -46,17 +41,4 @@ class JobsModule { clock: Clock, preferences: AppPreferences ): BackgroundJobManager = BackgroundJobManagerImpl(workManager, clock, preferences) - - @Provides - fun fileUploadOperationFactory( - uploadsStorageManager: UploadsStorageManager, - connectivityService: ConnectivityService, - powerManagementService: PowerManagementService, - context: Context - ): FileUploadOperationFactory = FileUploadOperationFactoryImpl( - uploadsStorageManager, - connectivityService, - powerManagementService, - context - ) } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 5ec404cf3568..0604e5db75f2 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -18,10 +18,10 @@ import com.nextcloud.client.database.entity.toUploadEntity import com.nextcloud.client.device.BatteryStatus import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.activeUploadFileOperations +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.currentUploadFileOperation +import com.nextcloud.client.notifications.AppWideNotificationManager import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService -import com.nextcloud.client.notifications.AppWideNotificationManager import com.nextcloud.utils.extensions.getUploadIds import com.owncloud.android.MainApp import com.owncloud.android.R @@ -372,14 +372,17 @@ class FileUploadHelper { @Suppress("ReturnCount") fun isUploadingNow(upload: OCUpload?): Boolean { - upload ?: return false - - return activeUploadFileOperations.values.any { operation -> - operation.user?.accountName == upload.accountName && - ( - upload.remotePath == operation.remotePath || - upload.remotePath == operation.oldFile?.remotePath - ) + val currentUploadFileOperation = currentUploadFileOperation + if (currentUploadFileOperation == null || currentUploadFileOperation.user == null) return false + if (upload == null || upload.accountName != currentUploadFileOperation.user.accountName) return false + + return if (currentUploadFileOperation.oldFile != null) { + // For file conflicts check old file remote path + upload.remotePath == currentUploadFileOperation.remotePath || + upload.remotePath == currentUploadFileOperation.oldFile!! + .remotePath + } else { + upload.remotePath == currentUploadFileOperation.remotePath } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadOperationFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadOperationFactory.kt deleted file mode 100644 index ecd5ac033590..000000000000 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadOperationFactory.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.nextcloud.client.jobs.upload - -import android.content.Context -import com.nextcloud.client.account.User -import com.nextcloud.client.device.PowerManagementService -import com.nextcloud.client.network.ConnectivityService -import com.owncloud.android.datamodel.FileDataStorageManager -import com.owncloud.android.datamodel.UploadsStorageManager -import com.owncloud.android.db.OCUpload -import com.owncloud.android.operations.UploadFileOperation -import javax.inject.Inject - -interface FileUploadOperationFactory { - fun create(upload: OCUpload, user: User, storageManager: FileDataStorageManager): UploadFileOperation -} - -class FileUploadOperationFactoryImpl @Inject constructor( - private val uploadsStorageManager: UploadsStorageManager, - private val connectivityService: ConnectivityService, - private val powerManagementService: PowerManagementService, - private val context: Context -) : FileUploadOperationFactory { - override fun create(upload: OCUpload, user: User, storageManager: FileDataStorageManager): UploadFileOperation = - UploadFileOperation( - uploadsStorageManager, - connectivityService, - powerManagementService, - user, - null, - upload, - upload.nameCollisionPolicy, - upload.localAction, - context, - upload.isUseWifiOnly, - upload.isWhileChargingOnly, - storageManager - ) -} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 2faf6988927c..275dce4b470c 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -20,7 +20,6 @@ import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.jobs.BackgroundJobManager import com.nextcloud.client.jobs.BackgroundJobManagerImpl import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager -import com.nextcloud.client.network.ClientFactory import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.model.WorkerState @@ -34,7 +33,9 @@ import com.owncloud.android.datamodel.ForegroundServiceType import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.OwnCloudAccount import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.network.OnDatatransferProgressListener import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode @@ -43,17 +44,10 @@ import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.ui.notifications.NotificationUtils import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import java.io.File -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger +import kotlin.random.Random @Suppress("LongParameterList", "TooGenericExceptionCaught") class FileUploadWorker( @@ -65,10 +59,7 @@ class FileUploadWorker( val localBroadcastManager: LocalBroadcastManager, private val backgroundJobManager: BackgroundJobManager, val preferences: AppPreferences, - val clientFactory: ClientFactory, - val uploadFileOperationFactory: FileUploadOperationFactory, val context: Context, - val notificationManager: UploadNotificationManager, params: WorkerParameters ) : CoroutineWorker(context, params), OnDatatransferProgressListener { @@ -82,10 +73,10 @@ class FileUploadWorker( const val UPLOAD_IDS = "uploads_ids" const val CURRENT_BATCH_INDEX = "batch_index" const val TOTAL_UPLOAD_SIZE = "total_upload_size" - const val SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION = "show_same_file_already_exists_notification" - val activeUploadFileOperations = ConcurrentHashMap() + var currentUploadFileOperation: UploadFileOperation? = null + private const val UPLOADS_ADDED_MESSAGE = "UPLOADS_ADDED" private const val UPLOAD_START_MESSAGE = "UPLOAD_START" private const val UPLOAD_FINISH_MESSAGE = "UPLOAD_FINISH" @@ -112,16 +103,20 @@ class FileUploadWorker( fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE fun cancelCurrentUpload(remotePath: String, accountName: String, onCompleted: () -> Unit) { - activeUploadFileOperations.values.forEach { + currentUploadFileOperation?.let { if (it.remotePath == remotePath && it.user.accountName == accountName) { it.cancel(ResultCode.USER_CANCELLED) + onCompleted() } } - onCompleted() } - fun isUploading(remotePath: String?, accountName: String?): Boolean = activeUploadFileOperations.values.any { - it.remotePath == remotePath && it.user.accountName == accountName + fun isUploading(remotePath: String?, accountName: String?): Boolean { + currentUploadFileOperation?.let { + return it.remotePath == remotePath && it.user.accountName == accountName + } + + return false } fun getUploadAction(action: String): Int = when (action) { @@ -132,9 +127,9 @@ class FileUploadWorker( } } - private val lastPercents = ConcurrentHashMap() - private val lastUpdateTimes = ConcurrentHashMap() - + private var lastPercent = 0 + private val notificationId = Random.nextInt() + private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId) private val intents = FileUploaderIntents(context) private val fileUploaderDelegate = FileUploaderDelegate() @@ -176,7 +171,7 @@ class FileUploadWorker( val notification = createNotification(notificationTitle) return ForegroundServiceHelper.createWorkerForegroundInfo( - notificationManager.getId(), + notificationId, notification, ForegroundServiceType.DataSync ) @@ -184,7 +179,7 @@ class FileUploadWorker( private suspend fun updateForegroundInfo(notification: Notification) { val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( - notificationManager.getId(), + notificationId, notification, ForegroundServiceType.DataSync ) @@ -207,7 +202,7 @@ class FileUploadWorker( Log_OC.e(TAG, "FileUploadWorker stopped") setIdleWorkerState() - activeUploadFileOperations.values.forEach { it.cancel(null) } + currentUploadFileOperation?.cancel(null) notificationManager.dismissNotification() } @@ -216,8 +211,7 @@ class FileUploadWorker( } private fun setIdleWorkerState() { - val lastOp = activeUploadFileOperations.values.lastOrNull() - WorkerStateObserver.send(WorkerState.FileUploadCompleted(lastOp?.file)) + WorkerStateObserver.send(WorkerState.FileUploadCompleted(currentUploadFileOperation?.file)) } @Suppress("ReturnCount", "LongMethod", "DEPRECATION") @@ -256,102 +250,55 @@ class FileUploadWorker( val user = optionalUser.get() val previouslyUploadedFileSize = currentBatchIndex * FileUploadHelper.MAX_FILE_COUNT val uploads = uploadsStorageManager.getUploadsByIds(uploadIds, accountName) + val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) + val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) - if (uploads.isNullOrEmpty()) { - return@withContext Result.success() - } - - val client = clientFactory.create(user) - - return@withContext parallelUpload( - uploads, - user, - previouslyUploadedFileSize, - totalUploadSize, - client, - accountName - ) - } - - private suspend fun parallelUpload( - uploads: List?, - user: User, - previouslyUploadedFileSize: Int, - totalUploadSize: Int, - client: OwnCloudClient, - accountName: String - ): Result { - if (uploads.isNullOrEmpty()) { - return Result.success() - } - - val semaphore = Semaphore(preferences.maxConcurrentUploads) - val quotaExceeded = AtomicBoolean(false) - val completedCount = AtomicInteger(0) - val storageManager = FileDataStorageManager(user, context.contentResolver) - - coroutineScope { - for (upload in uploads) { - if (quotaExceeded.get()) break - ensureActive() - - launch { - if (preferences.isGlobalUploadPaused) { - Log_OC.d(TAG, "Upload is paused, skip uploading files!") - notificationManager.notifyPaused(intents.openUploadListIntent(null)) - return@launch - } - - semaphore.withPermit { - if (quotaExceeded.get() || isStopped) return@launch - - if (canExitEarly()) { - notificationManager.showConnectionErrorNotification() - return@launch - } + for ((index, upload) in uploads.withIndex()) { + ensureActive() - setWorkerState(user) - val operation = createUploadFileOperation(upload, user, storageManager) - activeUploadFileOperations[operation.originalStoragePath] = operation - - try { - val currentUploadIndex = previouslyUploadedFileSize + completedCount.incrementAndGet() - - // Synchronize notification updates - synchronized(notificationManager) { - notificationManager.prepareForStart( - operation, - startIntent = intents.openUploadListIntent(operation), - currentUploadIndex = currentUploadIndex, - totalUploadSize = totalUploadSize - ) - } + if (preferences.isGlobalUploadPaused) { + Log_OC.d(TAG, "Upload is paused, skip uploading files!") + notificationManager.notifyPaused( + intents.openUploadListIntent(null) + ) + return@withContext Result.success() + } - val result = upload(operation, user, client) + if (canExitEarly()) { + notificationManager.showConnectionErrorNotification() + return@withContext Result.failure() + } - val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) - uploadsStorageManager.updateStatus(entity, result.isSuccess) + setWorkerState(user) + val operation = createUploadFileOperation(upload, user) + currentUploadFileOperation = operation - if (result.code == ResultCode.QUOTA_EXCEEDED) { - Log_OC.w(TAG, "Quota exceeded, stopping uploads") - notificationManager.showQuotaExceedNotification(operation) - quotaExceeded.set(true) - this@coroutineScope.cancel("Quota exceeded") - return@launch - } + val currentIndex = (index + 1) + val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) + notificationManager.prepareForStart( + operation, + startIntent = intents.openUploadListIntent(operation), + currentUploadIndex = currentUploadIndex, + totalUploadSize = totalUploadSize + ) - sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) - } finally { - activeUploadFileOperations.remove(operation.originalStoragePath) - lastPercents.remove(operation.originalStoragePath) - lastUpdateTimes.remove(operation.originalStoragePath) - } - } - } + val result = withContext(Dispatchers.IO) { + upload(operation, user, client) + } + val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) + uploadsStorageManager.updateStatus(entity, result.isSuccess) + currentUploadFileOperation = null + + if (result.code == ResultCode.QUOTA_EXCEEDED) { + Log_OC.w(TAG, "Quota exceeded, stopping uploads") + notificationManager.showQuotaExceedNotification(operation) + break } + + sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) } - return if (quotaExceeded.get()) Result.failure() else Result.success() + return@withContext Result.success() } private fun sendUploadFinishEvent( @@ -389,14 +336,20 @@ class FileUploadWorker( return result } - private fun createUploadFileOperation( - upload: OCUpload, - user: User, - storageManager: FileDataStorageManager - ): UploadFileOperation = uploadFileOperationFactory.create( - upload, + private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation( + uploadsStorageManager, + connectivityService, + powerManagementService, user, - storageManager + null, + upload, + upload.nameCollisionPolicy, + upload.localAction, + context, + upload.isUseWifiOnly, + upload.isWhileChargingOnly, + true, + FileDataStorageManager(user, context.contentResolver) ).apply { addDataTransferProgressListener(this@FileUploadWorker) } @@ -457,24 +410,20 @@ class FileUploadWorker( totalToTransfer: Long, fileAbsoluteName: String ) { - val operation = activeUploadFileOperations[fileAbsoluteName] ?: return val percent = getPercent(totalTransferredSoFar, totalToTransfer) val currentTime = System.currentTimeMillis() - val lastPercent = lastPercents[fileAbsoluteName] ?: 0 - val lastUpdateTime = lastUpdateTimes[fileAbsoluteName] ?: 0L - if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) { - synchronized(notificationManager) { - val accountName = operation.user.accountName - val remotePath = operation.remotePath + notificationManager.run { + val accountName = currentUploadFileOperation?.user?.accountName + val remotePath = currentUploadFileOperation?.remotePath - notificationManager.updateUploadProgress(percent, operation) + updateUploadProgress(percent, currentUploadFileOperation) if (accountName != null && remotePath != null) { val key: String = FileUploadHelper.buildRemoteName(accountName, remotePath) val boundListener = FileUploadHelper.mBoundListeners[key] - val filename = operation.fileName ?: "" + val filename = currentUploadFileOperation?.fileName ?: "" boundListener?.onTransferProgress( progressRate, @@ -484,10 +433,11 @@ class FileUploadWorker( ) } - notificationManager.dismissOldErrorNotification(operation) + dismissOldErrorNotification(currentUploadFileOperation) } - lastUpdateTimes[fileAbsoluteName] = currentTime - lastPercents[fileAbsoluteName] = percent + lastUpdateTime = currentTime } + + lastPercent = percent } } diff --git a/app/src/main/java/com/nextcloud/client/network/ClientFactoryImpl.java b/app/src/main/java/com/nextcloud/client/network/ClientFactoryImpl.java index ce6c53d0a824..22fe0c85fbf6 100644 --- a/app/src/main/java/com/nextcloud/client/network/ClientFactoryImpl.java +++ b/app/src/main/java/com/nextcloud/client/network/ClientFactoryImpl.java @@ -24,13 +24,10 @@ import java.io.IOException; -import javax.inject.Inject; - public class ClientFactoryImpl implements ClientFactory { private Context context; - @Inject public ClientFactoryImpl(Context context) { this.context = context; } diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java index d48314ac58ea..c511a5d2d287 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -848,7 +848,7 @@ public void setLastDisplayedAccountName(String lastDisplayedAccountName) { @Override public int getMaxConcurrentUploads() { - return preferences.getInt(PREF_MAX_CONCURRENT_UPLOADS, 10); + return Integer.parseInt(preferences.getString(PREF_MAX_CONCURRENT_UPLOADS, "10")); } @Override diff --git a/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt b/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt index b61d50a57a05..2af10230e3bc 100644 --- a/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt +++ b/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt @@ -21,9 +21,7 @@ import com.nextcloud.client.device.DeviceInfo import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.documentscan.GeneratePDFUseCase import com.nextcloud.client.integrations.deck.DeckApi -import com.nextcloud.client.jobs.upload.FileUploadOperationFactory import com.nextcloud.client.logger.Logger -import com.nextcloud.client.network.ClientFactory import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.MainApp @@ -106,12 +104,6 @@ class BackgroundJobFactoryTest { @Mock private lateinit var syncedFolderProvider: SyncedFolderProvider - @Mock - private lateinit var clientFactory: ClientFactory - - @Mock - private lateinit var fileUploadOperationFactory: FileUploadOperationFactory - @Mock private lateinit var db: NextcloudDatabase @@ -147,8 +139,6 @@ class BackgroundJobFactoryTest { { localBroadcastManager }, generatePDFUseCase, syncedFolderProvider, - clientFactory, - fileUploadOperationFactory, db ) } diff --git a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt deleted file mode 100644 index 63723a216e6a..000000000000 --- a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.nextcloud.client.jobs.upload - -import android.app.NotificationManager -import android.content.Context -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.work.ListenableWorker -import androidx.work.WorkerParameters -import com.nextcloud.android.common.ui.theme.MaterialSchemes -import com.nextcloud.client.account.User -import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.device.PowerManagementService -import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.client.network.Connectivity -import com.nextcloud.client.network.ConnectivityService -import com.nextcloud.client.preferences.AppPreferences -import com.owncloud.android.datamodel.UploadsStorageManager -import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode -import com.owncloud.android.operations.UploadFileOperation -import com.owncloud.android.utils.theme.ViewThemeUtils -import io.mockk.every -import io.mockk.mockk -import io.mockk.unmockkAll -import io.mockk.verify -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.util.Optional - -class FileUploadWorkerTest { - - private lateinit var worker: FileUploadWorker - private val uploadsStorageManager: UploadsStorageManager = mockk(relaxed = true) - private val connectivityService: ConnectivityService = mockk(relaxed = true) - private val powerManagementService: PowerManagementService = mockk(relaxed = true) - private val userAccountManager: UserAccountManager = mockk(relaxed = true) - private val localBroadcastManager: LocalBroadcastManager = mockk(relaxed = true) - private val backgroundJobManager: BackgroundJobManager = mockk(relaxed = true) - private val preferences: AppPreferences = mockk(relaxed = true) - private val clientFactory: ClientFactory = mockk(relaxed = true) - private val uploadFileOperationFactory: FileUploadOperationFactory = mockk(relaxed = true) - private val context: Context = mockk(relaxed = true) - private val params: WorkerParameters = mockk(relaxed = true) - private val systemNotificationManager: NotificationManager = mockk(relaxed = true) - private val uploadNotificationManager: UploadNotificationManager = mockk(relaxed = true) - - @Before - fun setUp() { - every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns systemNotificationManager - - val materialSchemes = mockk(relaxed = true) - val viewThemeUtils = ViewThemeUtils(materialSchemes, mockk(relaxed = true)) - - val connectivity = mockk() - every { connectivity.isConnected } returns true - every { connectivityService.getConnectivity() } returns connectivity - every { connectivityService.isConnected } returns true - every { connectivityService.isInternetWalled } returns false - - worker = FileUploadWorker( - uploadsStorageManager, - connectivityService, - powerManagementService, - userAccountManager, - viewThemeUtils, - localBroadcastManager, - backgroundJobManager, - preferences, - clientFactory, - uploadFileOperationFactory, - context, - uploadNotificationManager, - params - ) - } - - @After - fun tearDown() { - unmockkAll() - FileUploadWorker.activeUploadFileOperations.clear() - } - - @Test - fun `doWork returns failure when account name is missing`() = runBlocking { - // GIVEN - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns null - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.failure(), result) - } - - @Test - fun `doWork returns failure when upload ids are missing`() = runBlocking { - // GIVEN - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" - every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns null - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.failure(), result) - } - - @Test - fun `doWork returns failure when batch index is missing`() = runBlocking { - // GIVEN - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" - every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) - every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1) } returns -1 - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.failure(), result) - } - - @Test - fun `doWork returns failure when user is not found`() = runBlocking { - // GIVEN - val accountName = "account" - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns accountName - every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) - every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, any()) } returns 0 - every { params.inputData.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, any()) } returns 1 - every { userAccountManager.getUser(accountName) } returns Optional.empty() - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.failure(), result) - } - - @Test - fun `doWork returns success when there are no uploads`() = runBlocking { - // GIVEN - val accountName = "account" - val user = mockk(relaxed = true) - every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns accountName - every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) - every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, any()) } returns 0 - every { params.inputData.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, any()) } returns 1 - every { userAccountManager.getUser(accountName) } returns Optional.of(user) - every { uploadsStorageManager.getUploadsByIds(any(), accountName) } returns emptyList() - - // WHEN - val result = worker.doWork() - - // THEN - assertEquals(ListenableWorker.Result.success(), result) - } - - @Test - fun `onTransferProgress updates notification manager`() { - // GIVEN - val fileName = "testFile" - val operation = mockk(relaxed = true) - FileUploadWorker.activeUploadFileOperations[fileName] = operation - - // WHEN - worker.onTransferProgress(100, 50, 100, fileName) - - // THEN - verify { uploadNotificationManager.updateUploadProgress(50, operation) } - } - - @Test - fun `cancelCurrentUpload cancels matching operations`() { - // GIVEN - val remotePath = "path" - val accountName = "account" - val operation = mockk(relaxed = true) - every { operation.remotePath } returns remotePath - every { operation.user.accountName } returns accountName - FileUploadWorker.activeUploadFileOperations["key"] = operation - - // WHEN - var completed = false - FileUploadWorker.cancelCurrentUpload(remotePath, accountName) { - completed = true - } - - // THEN - verify { operation.cancel(ResultCode.USER_CANCELLED) } - assertTrue(completed) - } - - @Test - fun `isUploading returns true when operation exists`() { - // GIVEN - val remotePath = "path" - val accountName = "account" - val operation = mockk(relaxed = true) - every { operation.remotePath } returns remotePath - every { operation.user.accountName } returns accountName - FileUploadWorker.activeUploadFileOperations["key"] = operation - - // WHEN & THEN - assertTrue(FileUploadWorker.isUploading(remotePath, accountName)) - assertFalse(FileUploadWorker.isUploading("other", accountName)) - } - - @Test - fun `getUploadAction returns correct values`() { - assertEquals( - FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, - FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_FORGET") - ) - assertEquals( - FileUploadWorker.LOCAL_BEHAVIOUR_MOVE, - FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_MOVE") - ) - assertEquals( - FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, - FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_DELETE") - ) - assertEquals( - FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, - FileUploadWorker.getUploadAction("UNKNOWN") - ) - } -} From 76dc9be7b03ba246d98c2311c555f73dfa27783d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:43:43 +0000 Subject: [PATCH 078/125] fix(deps): update dependency androidx.compose.foundation:foundation to v1.10.1 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: Raphael Vieira --- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 88346b106da3..449da29789df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,7 +84,7 @@ spotless = "8.1.0" stateless4jVersion = "2.6.0" webkitVersion = "1.15.0" workRuntime = "2.11.0" -foundationVersion = "1.10.0" +foundationVersion = "1.10.1" [libraries] # Crypto diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4ccf5082aae2..2448cdf44c6c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -20004,17 +20004,14 @@ - - - - - - - - + + + + + + + + From cbc919d8be920b5dedcd026709e3b67cd13d93e7 Mon Sep 17 00:00:00 2001 From: Nextcloud bot Date: Tue, 20 Jan 2026 02:50:24 +0000 Subject: [PATCH 079/125] fix(l10n): Update translations from Transifex Signed-off-by: Nextcloud bot Signed-off-by: Raphael Vieira --- app/src/main/res/values-pt-rBR/strings.xml | 3 +++ app/src/main/res/values-th-rTH/strings.xml | 1 + 2 files changed, 4 insertions(+) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index fb833d6fd28f..7604db378a9a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -963,6 +963,9 @@ Sugerir Sincronizar Sincronizar mesmo assim + Resolver conflitos + Conflitos de upload detectados. Abra os uploads para resolver. + Conflitos de upload de arquivos Conflitos encontrados A pasta %1$s não existe mais Duplicação de sincronização diff --git a/app/src/main/res/values-th-rTH/strings.xml b/app/src/main/res/values-th-rTH/strings.xml index b445f08ddecb..09b04cba4eaa 100644 --- a/app/src/main/res/values-th-rTH/strings.xml +++ b/app/src/main/res/values-th-rTH/strings.xml @@ -351,6 +351,7 @@ อัปโหลดไฟล์ที่มีอยู่เดิมด้วย อัปโหลดขณะชาร์จเท่านั้น /อัพโหลดทันที + การแชร์ภายใน URL ไม่ถูกต้อง มองไม่เห็น ป้ายกำกับไม่สามารถเว้นว่างได้ From befe79996e4bd1ddceecbd4a8b209233bdae3a9a Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Tue, 20 Jan 2026 23:38:39 +0000 Subject: [PATCH 080/125] fixed FileUploadWorker to correctly update progress of uploading files, added necessary unit tests Signed-off-by: Raphael Vieira --- .../client/jobs/BackgroundJobManagerTest.kt | 2 - .../client/jobs/BackgroundJobFactory.kt | 3 + .../client/jobs/BackgroundJobManagerImpl.kt | 5 +- .../client/jobs/upload/FileUploadHelper.kt | 23 +- .../client/jobs/upload/FileUploadWorker.kt | 56 ++--- .../jobs/upload/FileUploadWorkerTest.kt | 213 ++++++++++++++++++ 6 files changed, 256 insertions(+), 46 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt index 9551f69ad5ae..c4426fb40b2c 100644 --- a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt @@ -403,10 +403,8 @@ class BackgroundJobManagerTest { val continuation: WorkContinuation = mock() whenever(workManager.beginUniqueWork(any(), any(), any>())).thenReturn(continuation) - backgroundJobManager.startFilesUploadJob(user, uploadIds, true) - val tagCaptor = argumentCaptor() val requestsCaptor = argumentCaptor>() diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 3e67df51fa92..6434300fabf1 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -29,6 +29,7 @@ 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.jobs.upload.UploadNotificationManager import com.nextcloud.client.logger.Logger import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences @@ -39,6 +40,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import org.greenrobot.eventbus.EventBus import javax.inject.Inject import javax.inject.Provider +import kotlin.random.Random /** * This factory is responsible for creating all background jobs and for injecting worker dependencies. @@ -238,6 +240,7 @@ class BackgroundJobFactory @Inject constructor( backgroundJobManager.get(), preferences, context, + UploadNotificationManager(context, viewThemeUtils.get(), Random.nextInt()), params ) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index a75abe464616..0374d0bd7ed6 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -46,7 +46,6 @@ import kotlinx.coroutines.launch import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit -import kotlin.math.round import kotlin.reflect.KClass /** @@ -688,11 +687,11 @@ internal class BackgroundJobManagerImpl( } if (workRequests.isNotEmpty()) { - workManager.beginUniqueWork( + workManager.enqueueUniqueWork( tag, ExistingWorkPolicy.KEEP, workRequests - ).enqueue() + ) } } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 0604e5db75f2..5ec404cf3568 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -18,10 +18,10 @@ import com.nextcloud.client.database.entity.toUploadEntity import com.nextcloud.client.device.BatteryStatus import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.currentUploadFileOperation -import com.nextcloud.client.notifications.AppWideNotificationManager +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.activeUploadFileOperations import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.notifications.AppWideNotificationManager import com.nextcloud.utils.extensions.getUploadIds import com.owncloud.android.MainApp import com.owncloud.android.R @@ -372,17 +372,14 @@ class FileUploadHelper { @Suppress("ReturnCount") fun isUploadingNow(upload: OCUpload?): Boolean { - val currentUploadFileOperation = currentUploadFileOperation - if (currentUploadFileOperation == null || currentUploadFileOperation.user == null) return false - if (upload == null || upload.accountName != currentUploadFileOperation.user.accountName) return false - - return if (currentUploadFileOperation.oldFile != null) { - // For file conflicts check old file remote path - upload.remotePath == currentUploadFileOperation.remotePath || - upload.remotePath == currentUploadFileOperation.oldFile!! - .remotePath - } else { - upload.remotePath == currentUploadFileOperation.remotePath + upload ?: return false + + return activeUploadFileOperations.values.any { operation -> + operation.user?.accountName == upload.accountName && + ( + upload.remotePath == operation.remotePath || + upload.remotePath == operation.oldFile?.remotePath + ) } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 275dce4b470c..5f8fd207a971 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -47,7 +47,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import java.io.File -import kotlin.random.Random +import java.util.concurrent.ConcurrentHashMap @Suppress("LongParameterList", "TooGenericExceptionCaught") class FileUploadWorker( @@ -60,6 +60,7 @@ class FileUploadWorker( private val backgroundJobManager: BackgroundJobManager, val preferences: AppPreferences, val context: Context, + val notificationManager: UploadNotificationManager, params: WorkerParameters ) : CoroutineWorker(context, params), OnDatatransferProgressListener { @@ -75,8 +76,7 @@ class FileUploadWorker( const val TOTAL_UPLOAD_SIZE = "total_upload_size" const val SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION = "show_same_file_already_exists_notification" - var currentUploadFileOperation: UploadFileOperation? = null - + val activeUploadFileOperations = ConcurrentHashMap() private const val UPLOADS_ADDED_MESSAGE = "UPLOADS_ADDED" private const val UPLOAD_START_MESSAGE = "UPLOAD_START" private const val UPLOAD_FINISH_MESSAGE = "UPLOAD_FINISH" @@ -103,20 +103,16 @@ class FileUploadWorker( fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE fun cancelCurrentUpload(remotePath: String, accountName: String, onCompleted: () -> Unit) { - currentUploadFileOperation?.let { + activeUploadFileOperations.values.forEach { if (it.remotePath == remotePath && it.user.accountName == accountName) { it.cancel(ResultCode.USER_CANCELLED) - onCompleted() } } + onCompleted() } - fun isUploading(remotePath: String?, accountName: String?): Boolean { - currentUploadFileOperation?.let { - return it.remotePath == remotePath && it.user.accountName == accountName - } - - return false + fun isUploading(remotePath: String?, accountName: String?): Boolean = activeUploadFileOperations.values.any { + it.remotePath == remotePath && it.user.accountName == accountName } fun getUploadAction(action: String): Int = when (action) { @@ -127,9 +123,9 @@ class FileUploadWorker( } } - private var lastPercent = 0 - private val notificationId = Random.nextInt() - private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId) + private val lastPercents = ConcurrentHashMap() + private val lastUpdateTimes = ConcurrentHashMap() + private val intents = FileUploaderIntents(context) private val fileUploaderDelegate = FileUploaderDelegate() @@ -171,7 +167,7 @@ class FileUploadWorker( val notification = createNotification(notificationTitle) return ForegroundServiceHelper.createWorkerForegroundInfo( - notificationId, + notificationManager.getId(), notification, ForegroundServiceType.DataSync ) @@ -179,7 +175,7 @@ class FileUploadWorker( private suspend fun updateForegroundInfo(notification: Notification) { val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( - notificationId, + notificationManager.getId(), notification, ForegroundServiceType.DataSync ) @@ -202,7 +198,7 @@ class FileUploadWorker( Log_OC.e(TAG, "FileUploadWorker stopped") setIdleWorkerState() - currentUploadFileOperation?.cancel(null) + activeUploadFileOperations.values.forEach { it.cancel(null) } notificationManager.dismissNotification() } @@ -211,7 +207,8 @@ class FileUploadWorker( } private fun setIdleWorkerState() { - WorkerStateObserver.send(WorkerState.FileUploadCompleted(currentUploadFileOperation?.file)) + val lastOp = activeUploadFileOperations.values.lastOrNull() + WorkerStateObserver.send(WorkerState.FileUploadCompleted(lastOp?.file)) } @Suppress("ReturnCount", "LongMethod", "DEPRECATION") @@ -271,7 +268,7 @@ class FileUploadWorker( setWorkerState(user) val operation = createUploadFileOperation(upload, user) - currentUploadFileOperation = operation + activeUploadFileOperations[operation.originalStoragePath] = operation val currentIndex = (index + 1) val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) @@ -287,7 +284,7 @@ class FileUploadWorker( } val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) uploadsStorageManager.updateStatus(entity, result.isSuccess) - currentUploadFileOperation = null + activeUploadFileOperations.remove(operation.originalStoragePath) if (result.code == ResultCode.QUOTA_EXCEEDED) { Log_OC.w(TAG, "Quota exceeded, stopping uploads") @@ -410,20 +407,24 @@ class FileUploadWorker( totalToTransfer: Long, fileAbsoluteName: String ) { + val operation = activeUploadFileOperations[fileAbsoluteName] ?: return val percent = getPercent(totalTransferredSoFar, totalToTransfer) val currentTime = System.currentTimeMillis() + val lastPercent = lastPercents[fileAbsoluteName] ?: 0 + val lastUpdateTime = lastUpdateTimes[fileAbsoluteName] ?: 0L + if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) { notificationManager.run { - val accountName = currentUploadFileOperation?.user?.accountName - val remotePath = currentUploadFileOperation?.remotePath + val accountName = operation.user.accountName + val remotePath = operation.remotePath - updateUploadProgress(percent, currentUploadFileOperation) + updateUploadProgress(percent, operation) if (accountName != null && remotePath != null) { val key: String = FileUploadHelper.buildRemoteName(accountName, remotePath) val boundListener = FileUploadHelper.mBoundListeners[key] - val filename = currentUploadFileOperation?.fileName ?: "" + val filename = operation.fileName ?: "" boundListener?.onTransferProgress( progressRate, @@ -433,11 +434,10 @@ class FileUploadWorker( ) } - dismissOldErrorNotification(currentUploadFileOperation) + dismissOldErrorNotification(operation) } - lastUpdateTime = currentTime + lastUpdateTimes[fileAbsoluteName] = currentTime + lastPercents[fileAbsoluteName] = percent } - - lastPercent = percent } } diff --git a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt new file mode 100644 index 000000000000..0651f10eff33 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt @@ -0,0 +1,213 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.jobs.upload + +import android.app.NotificationManager +import android.content.Context +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.nextcloud.android.common.ui.theme.MaterialSchemes +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.network.Connectivity +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.theme.ViewThemeUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Optional + +class FileUploadWorkerTest { + + private lateinit var worker: FileUploadWorker + private val uploadsStorageManager: UploadsStorageManager = mockk(relaxed = true) + private val connectivityService: ConnectivityService = mockk(relaxed = true) + private val powerManagementService: PowerManagementService = mockk(relaxed = true) + private val userAccountManager: UserAccountManager = mockk(relaxed = true) + private val localBroadcastManager: LocalBroadcastManager = mockk(relaxed = true) + private val backgroundJobManager: BackgroundJobManager = mockk(relaxed = true) + private val preferences: AppPreferences = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val params: WorkerParameters = mockk(relaxed = true) + private val systemNotificationManager: NotificationManager = mockk(relaxed = true) + private val uploadNotificationManager: UploadNotificationManager = mockk(relaxed = true) + + @Before + fun setUp() { + every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns systemNotificationManager + + val materialSchemes = mockk(relaxed = true) + val viewThemeUtils = ViewThemeUtils(materialSchemes, mockk(relaxed = true)) + + val connectivity = mockk() + every { connectivity.isConnected } returns true + every { connectivityService.getConnectivity() } returns connectivity + every { connectivityService.isConnected } returns true + every { connectivityService.isInternetWalled } returns false + + worker = FileUploadWorker( + uploadsStorageManager, + connectivityService, + powerManagementService, + userAccountManager, + viewThemeUtils, + localBroadcastManager, + backgroundJobManager, + preferences, + context, + uploadNotificationManager, + params + ) + } + + @After + fun tearDown() { + unmockkAll() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @Test + fun `doWork returns failure when account name is missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns null + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when upload ids are missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns null + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when batch index is missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) + every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1) } returns -1 + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when user is not found`() = runBlocking { + // GIVEN + val accountName = "account" + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns accountName + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) + every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, any()) } returns 0 + every { params.inputData.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, any()) } returns 1 + every { userAccountManager.getUser(accountName) } returns Optional.empty() + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `onTransferProgress updates notification manager`() { + // GIVEN + val fileName = "testFile" + val operation = mockk(relaxed = true) + FileUploadWorker.activeUploadFileOperations[fileName] = operation + + // WHEN + worker.onTransferProgress(100, 50, 100, fileName) + + // THEN + verify { uploadNotificationManager.updateUploadProgress(50, operation) } + } + + @Test + fun `cancelCurrentUpload cancels matching operations`() { + // GIVEN + val remotePath = "path" + val accountName = "account" + val operation = mockk(relaxed = true) + every { operation.remotePath } returns remotePath + every { operation.user.accountName } returns accountName + FileUploadWorker.activeUploadFileOperations["key"] = operation + + // WHEN + var completed = false + FileUploadWorker.cancelCurrentUpload(remotePath, accountName) { + completed = true + } + + // THEN + verify { operation.cancel(ResultCode.USER_CANCELLED) } + assertTrue(completed) + } + + @Test + fun `isUploading returns true when operation exists`() { + // GIVEN + val remotePath = "path" + val accountName = "account" + val operation = mockk(relaxed = true) + every { operation.remotePath } returns remotePath + every { operation.user.accountName } returns accountName + FileUploadWorker.activeUploadFileOperations["key"] = operation + + // WHEN & THEN + assertTrue(FileUploadWorker.isUploading(remotePath, accountName)) + assertFalse(FileUploadWorker.isUploading("other", accountName)) + } + + @Test + fun `getUploadAction returns correct values`() { + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_FORGET") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_MOVE, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_MOVE") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_DELETE") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + FileUploadWorker.getUploadAction("UNKNOWN") + ) + } +} From 3c5c5be05d905f42d6865d3ae75a139d285fb664 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Tue, 20 Jan 2026 23:43:13 +0000 Subject: [PATCH 081/125] ran spotless apply Signed-off-by: Raphael Vieira --- .../com/nextcloud/client/jobs/BackgroundJobManagerTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt index c4426fb40b2c..01d1de3837b1 100644 --- a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt @@ -396,12 +396,13 @@ class BackgroundJobManagerTest { @Test fun start_files_upload_job_enqueues_batches() { - val uploadIds = longArrayOf(1, 2, 3, 4, 5) whenever(preferences.maxConcurrentUploads).thenReturn(2) val continuation: WorkContinuation = mock() - whenever(workManager.beginUniqueWork(any(), any(), any>())).thenReturn(continuation) + whenever( + workManager.beginUniqueWork(any(), any(), any>()) + ).thenReturn(continuation) backgroundJobManager.startFilesUploadJob(user, uploadIds, true) From 811071068ff01e8d5c26221201ead09d705c856f Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Fri, 23 Jan 2026 23:24:00 +0000 Subject: [PATCH 082/125] removed max concurrent uploads preference. Defaulting to 7 threads Signed-off-by: Raphael Vieira --- .../client/jobs/BackgroundJobManagerImpl.kt | 2 +- .../nextcloud/client/preferences/AppPreferences.java | 3 --- .../client/preferences/AppPreferencesImpl.java | 12 ------------ app/src/main/res/xml/preferences.xml | 7 ------- .../client/preferences/TestAppPreferences.java | 12 ------------ 5 files changed, 1 insertion(+), 35 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 0374d0bd7ed6..66d1337ffd11 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -657,7 +657,7 @@ internal class BackgroundJobManagerImpl( */ override fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) { defaultDispatcherScope.launch { - val chunkSize = (uploadIds.size / preferences.maxConcurrentUploads).coerceAtLeast(1) + val chunkSize = (uploadIds.size / 7).coerceAtLeast(1) val batches = uploadIds.toList().chunked(chunkSize) val executionId = System.currentTimeMillis() val tag = "${startFileUploadJobTag(user.accountName)}_$executionId" diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java index 486857c5f9b8..699b2d927e87 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java @@ -396,7 +396,4 @@ default void onDarkThemeModeChanged(DarkMode mode) { String getLastDisplayedAccountName(); void setLastDisplayedAccountName(String lastDisplayedAccountName); - - int getMaxConcurrentUploads(); - void setMaxConcurrentUploads(int value); } diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java index c511a5d2d287..3d6330923ec6 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -111,8 +111,6 @@ public final class AppPreferencesImpl implements AppPreferences { private static final String PREF_LAST_DISPLAYED_ACCOUNT_NAME = "last_displayed_user"; - private static final String PREF_MAX_CONCURRENT_UPLOADS = "max_concurrent_uploads"; - private static final String LOG_ENTRY = "log_entry"; private final Context context; @@ -845,14 +843,4 @@ public String getLastDisplayedAccountName() { public void setLastDisplayedAccountName(String lastDisplayedAccountName) { preferences.edit().putString(PREF_LAST_DISPLAYED_ACCOUNT_NAME, lastDisplayedAccountName).apply(); } - - @Override - public int getMaxConcurrentUploads() { - return Integer.parseInt(preferences.getString(PREF_MAX_CONCURRENT_UPLOADS, "10")); - } - - @Override - public void setMaxConcurrentUploads(int value) { - preferences.edit().putInt(PREF_MAX_CONCURRENT_UPLOADS, value).apply(); - } } diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 521c057a4101..adada5180474 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -84,13 +84,6 @@ android:title="@string/prefs_all_files_access_title" android:key="allFilesAccess" android:summary="@string/prefs_all_files_access_summary" /> - - Date: Sat, 24 Jan 2026 01:09:25 +0000 Subject: [PATCH 083/125] fixed notifications manager and uploads list. Added relevant unit tests Signed-off-by: Raphael Vieira --- .../client/jobs/BackgroundJobManagerTest.kt | 21 +-- .../client/jobs/BackgroundJobManagerImpl.kt | 4 +- .../client/jobs/upload/FileUploadWorker.kt | 6 +- .../jobs/upload/FileUploadHelperTest.kt | 145 ++++++++++++++++++ 4 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt index 01d1de3837b1..1e3e50d86623 100644 --- a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt @@ -11,9 +11,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.test.annotation.UiThreadTest +import androidx.test.runner.screenshot.Screenshot import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest import androidx.work.WorkContinuation @@ -25,7 +27,12 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.utils.extensions.toByteArray import com.owncloud.android.lib.common.utils.Log_OC +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot import org.apache.commons.io.FileUtils +import org.bouncycastle.util.test.SimpleTest.runTest +import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -396,8 +403,7 @@ class BackgroundJobManagerTest { @Test fun start_files_upload_job_enqueues_batches() { - val uploadIds = longArrayOf(1, 2, 3, 4, 5) - whenever(preferences.maxConcurrentUploads).thenReturn(2) + val uploadIds = longArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) val continuation: WorkContinuation = mock() whenever( @@ -409,7 +415,7 @@ class BackgroundJobManagerTest { val tagCaptor = argumentCaptor() val requestsCaptor = argumentCaptor>() - verify(workManager, timeout(1000)).beginUniqueWork( + verify(workManager, timeout(1000)).enqueueUniqueWork( tagCaptor.capture(), eq(ExistingWorkPolicy.KEEP), requestsCaptor.capture() @@ -419,13 +425,13 @@ class BackgroundJobManagerTest { assertTrue(tag.startsWith(BackgroundJobManagerImpl.JOB_FILES_UPLOAD + USER_ACCOUNT_NAME + "_")) val requests = requestsCaptor.firstValue - assertEquals(3, requests.size) + assertEquals(6, requests.size) // Check first batch [1, 2] val data1 = requests[0].workSpec.input assertEquals(true, data1.getBoolean(FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false)) assertEquals(USER_ACCOUNT_NAME, data1.getString(FileUploadWorker.ACCOUNT)) - assertEquals(5, data1.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, 0)) + assertEquals(2, data1.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, 0)) assertTrue(longArrayOf(1, 2).contentEquals(data1.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) assertEquals(0, data1.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1)) @@ -436,16 +442,13 @@ class BackgroundJobManagerTest { // Check third batch [5] val data3 = requests[2].workSpec.input - assertTrue(longArrayOf(5).contentEquals(data3.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) + assertTrue(longArrayOf(5, 6).contentEquals(data3.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) assertEquals(2, data3.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1)) - - verify(continuation).enqueue() } @Test fun start_files_upload_job_does_nothing_when_empty() { val uploadIds = longArrayOf() - whenever(preferences.maxConcurrentUploads).thenReturn(2) backgroundJobManager.startFilesUploadJob(user, uploadIds, true) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 66d1337ffd11..66be2c4a5c17 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -657,7 +657,7 @@ internal class BackgroundJobManagerImpl( */ override fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) { defaultDispatcherScope.launch { - val chunkSize = (uploadIds.size / 7).coerceAtLeast(1) + val chunkSize = (uploadIds.size / 5).coerceAtLeast(1) val batches = uploadIds.toList().chunked(chunkSize) val executionId = System.currentTimeMillis() val tag = "${startFileUploadJobTag(user.accountName)}_$executionId" @@ -673,7 +673,7 @@ internal class BackgroundJobManagerImpl( showSameFileAlreadyExistsNotification ) .putString(FileUploadWorker.ACCOUNT, user.accountName) - .putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, uploadIds.size) + .putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, chunkSize) .putLongArray(FileUploadWorker.UPLOAD_IDS, batch.toLongArray()) .putInt(FileUploadWorker.CURRENT_BATCH_INDEX, index) .build() diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 5f8fd207a971..ab6d56c50dad 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -271,11 +271,11 @@ class FileUploadWorker( activeUploadFileOperations[operation.originalStoragePath] = operation val currentIndex = (index + 1) - val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) + notificationManager.prepareForStart( operation, startIntent = intents.openUploadListIntent(operation), - currentUploadIndex = currentUploadIndex, + currentUploadIndex = currentIndex, totalUploadSize = totalUploadSize ) @@ -292,7 +292,7 @@ class FileUploadWorker( break } - sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) + sendUploadFinishEvent(totalUploadSize, currentIndex, operation, result) } return@withContext Result.success() diff --git a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt new file mode 100644 index 000000000000..495c98924913 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt @@ -0,0 +1,145 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.jobs.upload + +import com.nextcloud.client.account.User +import com.nextcloud.client.di.AppComponent +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload +import com.owncloud.android.operations.UploadFileOperation +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FileUploadHelperTest { + + private lateinit var fileUploadHelper: FileUploadHelper + + @Before + fun setUp() { + mockkStatic(MainApp::class) + val appComponent = mockk(relaxed = true) + every { MainApp.getAppComponent() } returns appComponent + + fileUploadHelper = FileUploadHelper() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @After + fun tearDown() { + unmockkAll() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @Test + fun `isUploadingNow returns false for null upload`() { + assertFalse(fileUploadHelper.isUploadingNow(null)) + } + + @Test + fun `isUploadingNow returns false when no active operations`() { + val upload = mockk() + every { upload.accountName } returns "account" + every { upload.remotePath } returns "/file.txt" + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns true when remotePath matches`() { + val accountName = "account" + val remotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns remotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns remotePath + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertTrue(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns true when old remotePath matches`() { + val accountName = "account" + val remotePath = "/file_renamed.txt" + val oldRemotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns oldRemotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns remotePath + + val oldFile = mockk() + every { oldFile.remotePath } returns oldRemotePath + every { operation.oldFile } returns oldFile + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertTrue(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns false when accountName does not match`() { + val remotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns "account1" + every { upload.remotePath } returns remotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns "account2" + every { operation.user } returns user + every { operation.remotePath } returns remotePath + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns false when paths do not match`() { + val accountName = "account" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns "/other.txt" + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns "/file.txt" + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } +} From de400473feadb2010a9ca692d0b45cb81cff716a Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Sat, 24 Jan 2026 01:14:58 +0000 Subject: [PATCH 084/125] ran spotless apply Signed-off-by: Raphael Vieira --- .../jobs/upload/FileUploadHelperTest.kt | 290 +++++++++--------- 1 file changed, 145 insertions(+), 145 deletions(-) diff --git a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt index 495c98924913..78f2a29540b4 100644 --- a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt +++ b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt @@ -1,145 +1,145 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.nextcloud.client.jobs.upload - -import com.nextcloud.client.account.User -import com.nextcloud.client.di.AppComponent -import com.owncloud.android.MainApp -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.db.OCUpload -import com.owncloud.android.operations.UploadFileOperation -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkAll -import org.junit.After -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class FileUploadHelperTest { - - private lateinit var fileUploadHelper: FileUploadHelper - - @Before - fun setUp() { - mockkStatic(MainApp::class) - val appComponent = mockk(relaxed = true) - every { MainApp.getAppComponent() } returns appComponent - - fileUploadHelper = FileUploadHelper() - FileUploadWorker.activeUploadFileOperations.clear() - } - - @After - fun tearDown() { - unmockkAll() - FileUploadWorker.activeUploadFileOperations.clear() - } - - @Test - fun `isUploadingNow returns false for null upload`() { - assertFalse(fileUploadHelper.isUploadingNow(null)) - } - - @Test - fun `isUploadingNow returns false when no active operations`() { - val upload = mockk() - every { upload.accountName } returns "account" - every { upload.remotePath } returns "/file.txt" - - assertFalse(fileUploadHelper.isUploadingNow(upload)) - } - - @Test - fun `isUploadingNow returns true when remotePath matches`() { - val accountName = "account" - val remotePath = "/file.txt" - - val upload = mockk() - every { upload.accountName } returns accountName - every { upload.remotePath } returns remotePath - - val operation = mockk() - val user = mockk() - every { user.accountName } returns accountName - every { operation.user } returns user - every { operation.remotePath } returns remotePath - every { operation.oldFile } returns null - - FileUploadWorker.activeUploadFileOperations["key"] = operation - - assertTrue(fileUploadHelper.isUploadingNow(upload)) - } - - @Test - fun `isUploadingNow returns true when old remotePath matches`() { - val accountName = "account" - val remotePath = "/file_renamed.txt" - val oldRemotePath = "/file.txt" - - val upload = mockk() - every { upload.accountName } returns accountName - every { upload.remotePath } returns oldRemotePath - - val operation = mockk() - val user = mockk() - every { user.accountName } returns accountName - every { operation.user } returns user - every { operation.remotePath } returns remotePath - - val oldFile = mockk() - every { oldFile.remotePath } returns oldRemotePath - every { operation.oldFile } returns oldFile - - FileUploadWorker.activeUploadFileOperations["key"] = operation - - assertTrue(fileUploadHelper.isUploadingNow(upload)) - } - - @Test - fun `isUploadingNow returns false when accountName does not match`() { - val remotePath = "/file.txt" - - val upload = mockk() - every { upload.accountName } returns "account1" - every { upload.remotePath } returns remotePath - - val operation = mockk() - val user = mockk() - every { user.accountName } returns "account2" - every { operation.user } returns user - every { operation.remotePath } returns remotePath - every { operation.oldFile } returns null - - FileUploadWorker.activeUploadFileOperations["key"] = operation - - assertFalse(fileUploadHelper.isUploadingNow(upload)) - } - - @Test - fun `isUploadingNow returns false when paths do not match`() { - val accountName = "account" - - val upload = mockk() - every { upload.accountName } returns accountName - every { upload.remotePath } returns "/other.txt" - - val operation = mockk() - val user = mockk() - every { user.accountName } returns accountName - every { operation.user } returns user - every { operation.remotePath } returns "/file.txt" - every { operation.oldFile } returns null - - FileUploadWorker.activeUploadFileOperations["key"] = operation - - assertFalse(fileUploadHelper.isUploadingNow(upload)) - } -} +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.jobs.upload + +import com.nextcloud.client.account.User +import com.nextcloud.client.di.AppComponent +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload +import com.owncloud.android.operations.UploadFileOperation +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FileUploadHelperTest { + + private lateinit var fileUploadHelper: FileUploadHelper + + @Before + fun setUp() { + mockkStatic(MainApp::class) + val appComponent = mockk(relaxed = true) + every { MainApp.getAppComponent() } returns appComponent + + fileUploadHelper = FileUploadHelper() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @After + fun tearDown() { + unmockkAll() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @Test + fun `isUploadingNow returns false for null upload`() { + assertFalse(fileUploadHelper.isUploadingNow(null)) + } + + @Test + fun `isUploadingNow returns false when no active operations`() { + val upload = mockk() + every { upload.accountName } returns "account" + every { upload.remotePath } returns "/file.txt" + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns true when remotePath matches`() { + val accountName = "account" + val remotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns remotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns remotePath + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertTrue(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns true when old remotePath matches`() { + val accountName = "account" + val remotePath = "/file_renamed.txt" + val oldRemotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns oldRemotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns remotePath + + val oldFile = mockk() + every { oldFile.remotePath } returns oldRemotePath + every { operation.oldFile } returns oldFile + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertTrue(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns false when accountName does not match`() { + val remotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns "account1" + every { upload.remotePath } returns remotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns "account2" + every { operation.user } returns user + every { operation.remotePath } returns remotePath + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns false when paths do not match`() { + val accountName = "account" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns "/other.txt" + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns "/file.txt" + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } +} From 7f600ab7c0a93e0b10bcb897c880f4f37110b12c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 14:39:22 +0100 Subject: [PATCH 085/125] ecosystem link handling Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- app/build.gradle.kts | 3 +- app/src/main/AndroidManifest.xml | 7 ++ .../java/com/nextcloud/utils/LinkHelper.kt | 64 +------------------ .../android/ui/activity/DrawerActivity.java | 18 +++++- .../ui/activity/FileDisplayActivity.kt | 22 +++++++ .../android/ui/adapter/OCFileListAdapter.java | 10 ++- gradle/libs.versions.toml | 3 +- gradle/verification-metadata.xml | 29 +++++++++ 8 files changed, 87 insertions(+), 69 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a665df2c229a..66e67a745f93 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -504,8 +504,9 @@ dependencies { "gplayImplementation"(libs.bundles.gplay) // endregion - // region UI + // region common implementation(libs.ui) + implementation(libs.common.core) // endregion // region Image loading diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6a210d519cb1..b9589dd3854c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -147,6 +147,13 @@ android:exported="true" android:launchMode="singleTop" android:theme="@style/Theme.ownCloud.Launcher"> + + + + + + + diff --git a/app/src/main/java/com/nextcloud/utils/LinkHelper.kt b/app/src/main/java/com/nextcloud/utils/LinkHelper.kt index 294c98d7b748..75ee63098667 100644 --- a/app/src/main/java/com/nextcloud/utils/LinkHelper.kt +++ b/app/src/main/java/com/nextcloud/utils/LinkHelper.kt @@ -10,53 +10,17 @@ package com.nextcloud.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.net.Uri import androidx.core.net.toUri -import com.nextcloud.client.account.User import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.ui.activity.FileDisplayActivity import java.util.Locale -import java.util.Optional -import kotlin.jvm.optionals.getOrNull object LinkHelper { - const val APP_NEXTCLOUD_NOTES = "it.niedermann.owncloud.notes" - const val APP_NEXTCLOUD_TALK = "com.nextcloud.talk2" private const val TAG = "LinkHelper" fun isHttpOrHttpsLink(link: String?): Boolean = link?.lowercase(Locale.getDefault())?.let { it.startsWith("http://") || it.startsWith("https://") } == true - /** - * Open specified app and, if not installed redirect to corresponding download. - * - * @param packageName of app to be opened - * @param user to pass in intent - */ - fun openAppOrStore(packageName: String, user: Optional, context: Context) { - openAppOrStore(packageName, user.getOrNull(), context) - } - - /** - * Open specified app and, if not installed redirect to corresponding download. - * - * @param packageName of app to be opened - * @param user to pass in intent - */ - fun openAppOrStore(packageName: String, user: User?, context: Context) { - val intent = context.packageManager.getLaunchIntentForPackage(packageName) - if (intent != null) { - // app installed - open directly - // TODO handle null user? - intent.putExtra(FileDisplayActivity.KEY_ACCOUNT, user.hashCode()) - context.startActivity(intent) - } else { - // app not found - open market (Google Play Store, F-Droid, etc.) - openAppStore(packageName, false, context) - } - } - /** * Open app store page of specified app or search for specified string. Will attempt to open browser when no app * store is available. @@ -69,7 +33,7 @@ object LinkHelper { val intent = Intent(Intent.ACTION_VIEW, "market://$suffix".toUri()) try { context.startActivity(intent) - } catch (activityNotFoundException1: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { // all is lost: open google play store web page for app if (!search) { suffix = "apps/$suffix" @@ -82,32 +46,6 @@ object LinkHelper { // region Validation private const val HTTP = "http" private const val HTTPS = "https" - private const val FILE = "file" - private const val CONTENT = "content" - - /** - * Validates if a string can be converted to a valid URI - */ - @Suppress("TooGenericExceptionCaught", "ReturnCount") - fun validateAndGetURI(uriString: String?): Uri? { - if (uriString.isNullOrBlank()) { - Log_OC.w(TAG, "Given uriString is null or blank") - return null - } - - return try { - val uri = uriString.toUri() - if (uri.scheme == null) { - return null - } - - val validSchemes = listOf(HTTP, HTTPS, FILE, CONTENT) - if (uri.scheme in validSchemes) uri else null - } catch (e: Exception) { - Log_OC.e(TAG, "Invalid URI string: $uriString -- $e") - null - } - } /** * Validates if a URL string is valid diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 29849c71f0c5..13d4cdca534a 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -49,6 +49,8 @@ import com.google.android.material.button.MaterialButton; import com.google.android.material.navigation.NavigationView; import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.nextcloud.android.common.core.utils.ecosystem.EcosystemApp; +import com.nextcloud.android.common.core.utils.ecosystem.EcosystemManager; import com.nextcloud.android.common.ui.theme.utils.ColorRole; import com.nextcloud.client.account.User; import com.nextcloud.client.di.Injectable; @@ -205,6 +207,8 @@ public abstract class DrawerActivity extends ToolbarActivity private BottomNavigationView bottomNavigationView; + private EcosystemManager ecosystemManager; + @Inject AppPreferences preferences; @@ -429,8 +433,13 @@ private void showTopBanner(ConstraintLayout banner) { LinearLayout moreView = banner.findViewById(R.id.drawer_ecosystem_more); LinearLayout assistantView = banner.findViewById(R.id.drawer_ecosystem_assistant); - notesView.setOnClickListener(v -> LinkHelper.INSTANCE.openAppOrStore(LinkHelper.APP_NEXTCLOUD_NOTES, getUser(), this)); - talkView.setOnClickListener(v -> LinkHelper.INSTANCE.openAppOrStore(LinkHelper.APP_NEXTCLOUD_TALK, getUser(), this)); + final var optionalUser = getUser(); + if (optionalUser.isPresent()) { + final var accountName = optionalUser.get().getAccountName(); + notesView.setOnClickListener(v -> ecosystemManager.openApp(EcosystemApp.NOTES, accountName)); + talkView.setOnClickListener(v -> ecosystemManager.openApp(EcosystemApp.TALK, accountName)); + } + moreView.setOnClickListener(v -> LinkHelper.INSTANCE.openAppStore("Nextcloud", true, this)); assistantView.setOnClickListener(v -> { DrawerActivity.menuItemId = Menu.NONE; @@ -727,6 +736,10 @@ private void launchActivityForSearch(SearchEvent searchEvent, int menuItemId) { startActivity(intent); } + public EcosystemManager getEcosystemManager() { + return ecosystemManager; + } + /** * sets the new/current account and restarts. In case the given account equals the actual/current account the call * will be ignored. @@ -1136,6 +1149,7 @@ protected void onCreate(Bundle savedInstanceState) { externalLinksProvider = new ExternalLinksProvider(getContentResolver()); arbitraryDataProvider = new ArbitraryDataProviderImpl(this); + ecosystemManager = new EcosystemManager(this); } @Override diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index cd383b4241da..6bb0e8e19987 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -52,6 +52,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.google.android.material.appbar.AppBarLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.common.core.utils.ecosystem.AccountReceiverCallback import com.nextcloud.appReview.InAppReviewHelper import com.nextcloud.client.account.User import com.nextcloud.client.appinfo.AppInfo @@ -547,6 +548,7 @@ class FileDisplayActivity : handleCommonIntents(intent) handleSpecialIntents(intent) handleRestartIntent(intent) + handleEcosystemIntent(intent) } private fun handleSpecialIntents(intent: Intent) { @@ -3073,6 +3075,26 @@ class FileDisplayActivity : }) } + private fun handleEcosystemIntent(intent: Intent?) { + ecosystemManager.receiveAccount( + intent, + object : AccountReceiverCallback { + override fun onAccountReceived(accountName: String) { + val user = accountManager.getUser(accountName) + if (user.isPresent) { + accountClicked(user.get()) + } else { + Log_OC.e(TAG, "user is not present") + } + } + + override fun onAccountError(reason: String) { + Log_OC.w(TAG, "handleEcosystemIntent: $reason") + } + } + ) + } + // region MetadataSyncJob private fun startMetadataSyncForRoot() { backgroundJobManager.startMetadataSyncJob(OCFile.ROOT_PATH) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java index 08f690ccbe93..603a4f864ec0 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java @@ -26,13 +26,13 @@ import com.elyeproj.loaderviewlibrary.LoaderImageView; import com.google.android.material.chip.Chip; +import com.nextcloud.android.common.core.utils.ecosystem.EcosystemApp; import com.nextcloud.android.common.ui.theme.utils.ColorRole; import com.nextcloud.client.account.User; import com.nextcloud.client.database.entity.OfflineOperationEntity; import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.model.OfflineOperationType; -import com.nextcloud.utils.LinkHelper; import com.nextcloud.utils.extensions.OCFileExtensionsKt; import com.nextcloud.utils.extensions.ViewExtensionsKt; import com.nextcloud.utils.mdm.MDMConfig; @@ -55,6 +55,7 @@ import com.owncloud.android.lib.resources.status.OCCapability; import com.owncloud.android.lib.resources.tags.Tag; import com.owncloud.android.ui.activity.ComponentsGetter; +import com.owncloud.android.ui.activity.DrawerActivity; import com.owncloud.android.ui.activity.FileDisplayActivity; import com.owncloud.android.ui.adapter.helper.OCFileListAdapterDataProvider; import com.owncloud.android.ui.adapter.helper.OCFileListAdapterHelper; @@ -449,7 +450,12 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi listHeaderOpenInBinding.openInButton.setText(String.format(activity.getString(R.string.open_in_app), activity.getString(R.string.ecosystem_apps_display_notes))); - listHeaderOpenInBinding.openInButton.setOnClickListener(v -> LinkHelper.INSTANCE.openAppOrStore(LinkHelper.APP_NEXTCLOUD_NOTES, user, activity)); + if (activity instanceof DrawerActivity drawerActivity) { + final var ecosystemManager = drawerActivity.getEcosystemManager(); + if (ecosystemManager != null) { + listHeaderOpenInBinding.openInButton.setOnClickListener(v -> ecosystemManager.openApp(EcosystemApp.NOTES, user.getAccountName())); + } + } } } else { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 449da29789df..dc094ccbd63c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later [versions] -androidCommonLibraryVersion = "0.31.0" +androidCommonLibraryVersion = "c7da76323d" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" androidLibraryVersion ="839366c261c52437425cde740c9e5a4ab8c8bace" @@ -232,6 +232,7 @@ prism4j-bundler = { module = "io.noties:prism4j-bundler", version.ref = "prismVe # Nextcloud libraries ui = { module = "com.github.nextcloud.android-common:ui", version.ref = "androidCommonLibraryVersion" } +common-core = { module = "com.github.nextcloud.android-common:core", version.ref = "androidCommonLibraryVersion" } qrcodescanner = { module = "com.github.nextcloud-deps:qrcodescanner", version.ref = "qrcodescannerVersion" } # Worker diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2448cdf44c6c..47fad4241131 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1450,6 +1450,11 @@ + + + + + @@ -20769,6 +20774,14 @@ + + + + + + + + @@ -20949,6 +20962,14 @@ + + + + + + + + @@ -21125,6 +21146,14 @@ + + + + + + + + From 74b27b8c68208e78aec6db2c5342c6d51faf633c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 15:24:40 +0100 Subject: [PATCH 086/125] ecosystem link handling Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../ui/activity/FileDisplayActivity.kt | 1 + gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 24 +++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 6bb0e8e19987..25d6a53b7120 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -257,6 +257,7 @@ class FileDisplayActivity : intent?.let { handleCommonIntents(it) + handleEcosystemIntent(it) } loadSavedInstanceState(savedInstanceState) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc094ccbd63c..0208119d9b67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later [versions] -androidCommonLibraryVersion = "c7da76323d" +androidCommonLibraryVersion = "3babd42636" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" androidLibraryVersion ="839366c261c52437425cde740c9e5a4ab8c8bace" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 47fad4241131..74e934450096 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -20742,6 +20742,14 @@ + + + + + + + + @@ -20930,6 +20938,14 @@ + + + + + + + + @@ -21114,6 +21130,14 @@ + + + + + + + + From dc072a229f497ea673c98c0c3eb7bea6e4ed06a2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 16 Jan 2026 09:10:17 +0100 Subject: [PATCH 087/125] inform user if account not exists Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../owncloud/android/ui/activity/FileDisplayActivity.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 25d6a53b7120..de665a064c47 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -3082,10 +3082,14 @@ class FileDisplayActivity : object : AccountReceiverCallback { override fun onAccountReceived(accountName: String) { val user = accountManager.getUser(accountName) + if (user.isEmpty) { + Log_OC.e(TAG, "user is not present") + DisplayUtils.showSnackMessage(this@FileDisplayActivity, R.string.account_not_found) + return + } + if (user.isPresent) { accountClicked(user.get()) - } else { - Log_OC.e(TAG, "user is not present") } } From cd7a094c3fb48b2f7eb7adfafa6c9af3ee99c6b4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 16 Jan 2026 09:12:43 +0100 Subject: [PATCH 088/125] inform user if account not exists Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../android/ui/activity/FileDisplayActivity.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index de665a064c47..cc27ee6c40d7 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -3081,16 +3081,14 @@ class FileDisplayActivity : intent, object : AccountReceiverCallback { override fun onAccountReceived(accountName: String) { - val user = accountManager.getUser(accountName) - if (user.isEmpty) { - Log_OC.e(TAG, "user is not present") - DisplayUtils.showSnackMessage(this@FileDisplayActivity, R.string.account_not_found) - return - } + val account = accountManager.getUser(accountName).orElse(null) + ?: run { + Log_OC.w(TAG, "user is not present") + DisplayUtils.showSnackMessage(this@FileDisplayActivity, R.string.account_not_found) + return + } - if (user.isPresent) { - accountClicked(user.get()) - } + accountClicked(account) } override fun onAccountError(reason: String) { From 5b9e43fc7504a2f21de3473cac4aad2b63aa3736 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 16 Jan 2026 09:55:37 +0100 Subject: [PATCH 089/125] update lib Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0208119d9b67..cdf7d626d223 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later [versions] -androidCommonLibraryVersion = "3babd42636" +androidCommonLibraryVersion = "30a17d42f4" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" androidLibraryVersion ="839366c261c52437425cde740c9e5a4ab8c8bace" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 74e934450096..b59187f95902 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -20742,6 +20742,14 @@ + + + + + + + + @@ -20938,6 +20946,14 @@ + + + + + + + + @@ -21130,6 +21146,14 @@ + + + + + + + + From a137762dc1a0cb4f75bb5ebb5e2e2ed909239136 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 19 Jan 2026 09:26:07 +0100 Subject: [PATCH 090/125] update lib Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cdf7d626d223..0a3071a3396c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later [versions] -androidCommonLibraryVersion = "30a17d42f4" +androidCommonLibraryVersion = "4fc0f29981" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" androidLibraryVersion ="839366c261c52437425cde740c9e5a4ab8c8bace" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index b59187f95902..1284790eaae0 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -20774,6 +20774,14 @@ + + + + + + + + @@ -20978,6 +20986,14 @@ + + + + + + + + @@ -21178,6 +21194,14 @@ + + + + + + + + From eb947d46ec15dd02de328a86b57a5cf7327aa702 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Fri, 16 Jan 2026 12:20:36 +0100 Subject: [PATCH 091/125] Bump to Gradle 9 Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- app/build.gradle.kts | 13 ++++++------- gradle.properties | 10 ++++++++++ gradle/verification-metadata.xml | 10 ++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 66e67a745f93..613ec1b335d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,12 @@ android { androidResources.generateLocaleConfig = true defaultConfig { + testInstrumentationRunnerArguments += mapOf( + "TEST_SERVER_URL" to "ncTestServerBaseUrl.toString()", + "TEST_SERVER_USERNAME" to "ncTestServerUsername.toString()", + "TEST_SERVER_PASSWORD" to "ncTestServerPassword.toString()", + "disableAnalytics" to "true" + ) applicationId = "com.nextcloud.client" minSdk = 28 targetSdk = 36 @@ -109,13 +115,6 @@ android { 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}" diff --git a/gradle.properties b/gradle.properties index 50cc48359122..36960b3445b8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,6 +20,16 @@ org.gradle.configureondemand=true kapt.incremental.apt=true org.gradle.daemon=true org.gradle.configuration-cache=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false # Needed for local libs # org.gradle.dependency.verification=lenient diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 1284790eaae0..1675d05d5bf7 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -14066,6 +14066,16 @@ + + + + + + + + From d3e9294d7b50ae978eaa361638d1970b13687e94 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Mon, 19 Jan 2026 08:31:05 +0100 Subject: [PATCH 092/125] lint Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- .../main/java/com/nextcloud/client/assistant/task/TaskView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt b/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt index d0792f6b08df..cedd198eeee2 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt @@ -133,7 +133,7 @@ private fun TaskViewPreview() { TaskInput("What about other promising tokens like"), TaskOutput( "Several tokens show promise for future growth in the" + - "cryptocurrency market" + " cryptocurrency market" ), 1707692337, 1707692337, From 91a3d4ebec8d51cd433bda8354264af5d7439395 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 20 Jan 2026 10:48:46 +0100 Subject: [PATCH 093/125] todo for gradle.properties Signed-off-by: tobiasKaminsky Signed-off-by: Raphael Vieira --- gradle.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gradle.properties b/gradle.properties index 36960b3445b8..bb4fa42709f2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,6 +20,9 @@ org.gradle.configureondemand=true kapt.incremental.apt=true org.gradle.daemon=true org.gradle.configuration-cache=true +# automatically aded via AGP migration +# see https://developer.android.com/build/releases/agp-9-0-0-release-notes +# should be changed with https://github.com/nextcloud/android/issues/15993 android.defaults.buildfeatures.resvalues=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false android.enableAppCompileTimeRClass=false From 4a5a60660ef72ec3d01e446f93ee468bdc210f4a Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Mon, 19 Jan 2026 14:22:49 +0100 Subject: [PATCH 094/125] Gradle 9 Signed-off-by: Tobias Kaminsky Signed-off-by: tobiasKaminsky # Conflicts: # gradle.properties Signed-off-by: Raphael Vieira --- gradle.properties | 3 --- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/gradle.properties b/gradle.properties index bb4fa42709f2..36960b3445b8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,9 +20,6 @@ org.gradle.configureondemand=true kapt.incremental.apt=true org.gradle.daemon=true org.gradle.configuration-cache=true -# automatically aded via AGP migration -# see https://developer.android.com/build/releases/agp-9-0-0-release-notes -# should be changed with https://github.com/nextcloud/android/issues/15993 android.defaults.buildfeatures.resvalues=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false android.enableAppCompileTimeRClass=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a3071a3396c..1b18ff7db236 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidCommonLibraryVersion = "4fc0f29981" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" androidLibraryVersion ="839366c261c52437425cde740c9e5a4ab8c8bace" -androidPluginVersion = '8.13.2' +androidPluginVersion = "9.0.0" androidsvgVersion = "1.4" androidxMediaVersion = "1.5.1" androidxTestVersion = "1.7.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2b5432..19a6bdeb848a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 2e8e845161728d12bd223a532bf4485d170093a7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 20 Jan 2026 14:10:24 +0100 Subject: [PATCH 095/125] fix build Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- gradle.properties | 4 ++++ gradle/verification-metadata.xml | 28 +++++++++++++++++----------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/gradle.properties b/gradle.properties index 36960b3445b8..677668280a6d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,6 +20,10 @@ org.gradle.configureondemand=true kapt.incremental.apt=true org.gradle.daemon=true org.gradle.configuration-cache=true + +# automatically aded via AGP migration +# see https://developer.android.com/build/releases/agp-9-0-0-release-notes +# should be changed with https://github.com/nextcloud/android/issues/15993 android.defaults.buildfeatures.resvalues=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false android.enableAppCompileTimeRClass=false diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 1675d05d5bf7..f6fca11c340b 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -101,7 +101,10 @@ - + + + + @@ -14066,16 +14069,14 @@ - - - - - - - - + + + + + + + + @@ -26216,6 +26217,11 @@ + + + + + From 55d896e6db194d1f0dd83c1c0a60480f2de94eeb Mon Sep 17 00:00:00 2001 From: ZetaTom <70907959+ZetaTom@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:35:05 +0100 Subject: [PATCH 096/125] fix(navigation): show folder name in SharedListFragment actionbar Signed-off-by: ZetaTom <70907959+ZetaTom@users.noreply.github.com> Signed-off-by: Raphael Vieira --- .../com/owncloud/android/ui/fragment/OCFileListFragment.java | 5 ++++- .../com/owncloud/android/ui/fragment/SharedListFragment.kt | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 909b3e11ac4c..df4726988db8 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -2234,6 +2234,9 @@ private boolean isSearchEvent(SearchRemoteOperation.SearchType givenEvent) { } public boolean shouldNavigateBackToAllFiles() { - return ((this instanceof GalleryFragment) || isSearchEventFavorite() || DrawerActivity.menuItemId == R.id.nav_favorites); + return this instanceof GalleryFragment || + isSearchEventFavorite() || + isSearchEventShared() || + DrawerActivity.menuItemId == R.id.nav_favorites; } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt index 9756099b7e15..08e307aeae1e 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt @@ -61,7 +61,6 @@ class SharedListFragment : Handler().post { if (activity is FileDisplayActivity) { val fileDisplayActivity = activity as FileDisplayActivity - fileDisplayActivity.updateActionBarTitleAndHomeButtonByString(getString(R.string.drawer_item_shared)) fileDisplayActivity.setMainFabVisible(false) } } From 26b0d31edb09713f01bb6633a90faf65ff4b5a25 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 19 Jan 2026 09:54:01 +0100 Subject: [PATCH 097/125] fix(upload-notification): dismiss error notification Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../jobs/notification/WorkerNotificationManager.kt | 4 ++++ .../client/jobs/upload/UploadNotificationManager.kt | 13 +------------ .../android/ui/activity/ConflictsResolveActivity.kt | 9 +++++---- .../android/ui/notifications/NotificationUtils.kt | 3 --- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt index e5c9f31bb73b..195a11b72a34 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt @@ -57,6 +57,10 @@ open class WorkerNotificationManager( notificationManager.notify(id, notification) } + fun dismissNotification(id: Int) { + notificationManager.cancel(id) + } + @Suppress("MagicNumber") fun setProgress(percent: Int, progressText: String?, indeterminate: Boolean) { notificationBuilder.run { diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt index c01d9a9aa399..912334539c95 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadNotificationManager.kt @@ -116,22 +116,11 @@ class UploadNotificationManager(private val context: Context, viewThemeUtils: Vi return } - dismissOldErrorNotification(operation.file.remotePath, operation.file.storagePath) - - operation.oldFile?.let { - dismissOldErrorNotification(it.remotePath, it.storagePath) - } + dismissNotification(operation.ocUploadId.toInt()) } fun dismissErrorNotification() = notificationManager.cancel(FileUploadWorker.NOTIFICATION_ERROR_ID) - fun dismissOldErrorNotification(remotePath: String, localPath: String) { - notificationManager.cancel( - NotificationUtils.createUploadNotificationTag(remotePath, localPath), - FileUploadWorker.NOTIFICATION_ERROR_ID - ) - } - fun notifyPaused(intent: PendingIntent) { notificationBuilder.run { setContentTitle(context.getString(R.string.upload_global_pause_title)) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt index c6901374ca30..0ccc8cf0d810 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt @@ -230,12 +230,13 @@ class ConflictsResolveActivity : upload?.let { FileUploadHelper.instance().removeFileUpload(it.remotePath, it.accountName) - - UploadNotificationManager( + val id = it.uploadId.toInt() + val nm = UploadNotificationManager( applicationContext, viewThemeUtils, - upload.uploadId.toInt() - ).dismissOldErrorNotification(it.remotePath, it.localPath) + id + ) + nm.dismissNotification(id) } } diff --git a/app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.kt b/app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.kt index a116937bebcb..d4172a4f3108 100644 --- a/app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.kt +++ b/app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.kt @@ -22,7 +22,4 @@ object NotificationUtils { const val NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS: String = "NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS" const val NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS: String = "NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS" const val NOTIFICATION_CHANNEL_CONTENT_OBSERVER: String = "NOTIFICATION_CHANNEL_CONTENT_OBSERVER" - - @JvmStatic - fun createUploadNotificationTag(remotePath: String?, localPath: String): String = remotePath + localPath } From 1b997be6429519e213ea9012cf7ededc0b9ea609 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 16 Jan 2026 12:05:17 +0100 Subject: [PATCH 098/125] fix(tabs-list-mode): layout switch Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../preferences/AppPreferencesImpl.java | 20 ++++--- .../android/ui/activity/DrawerActivity.java | 7 ++- .../ui/activity/FileDisplayActivity.kt | 1 - .../ui/fragment/ExtendedListFragment.kt | 8 ++- .../ui/fragment/LocalFileListFragment.java | 4 +- .../ui/fragment/OCFileListFragment.java | 52 +++++++++++-------- 6 files changed, 56 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java index 3d6330923ec6..682e211fcd5a 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -655,20 +655,26 @@ public long getPhotoSearchTimestamp() { } /** - * Get preference value for a folder. If folder is not set itself, it finds an ancestor that is set. + * Retrieves a preference value for a specific folder. + *

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

+ * Anonymous users or {@code null} folders will always return the {@code defaultValue}. * - * @param context Context object. - * @param preferenceName Name of the preference to lookup. - * @param folder Folder. - * @param defaultValue Fallback value in case no ancestor is set. - * @return Preference value + * @param context The Android context. + * @param user The user for whom the preference is queried. + * @param preferenceName The name/key of the preference to look up. + * @param folder The folder to check. + * @param defaultValue The value to return if no preference is set in the folder hierarchy. + * @return The preference value for the folder, or {@code defaultValue} if none is set. */ private static String getFolderPreference(final Context context, final User user, final String preferenceName, final OCFile folder, final String defaultValue) { - if (user.isAnonymous()) { + if (user.isAnonymous() || folder == null) { return defaultValue; } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 13d4cdca534a..4f089971528f 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -557,6 +557,8 @@ private void onNavigationItemClicked(final MenuItem menuItem) { } closeDrawer(); + setupHomeSearchToolbarWithSortAndListButtons(); + updateActionBarTitleAndHomeButton(null); } else if (itemId == R.id.nav_favorites) { openFavoritesTab(); } else if (itemId == R.id.nav_gallery) { @@ -630,6 +632,8 @@ private void handleBottomNavigationViewClicks() { fda.browseToRoot(); } EventBus.getDefault().post(new ChangeMenuEvent()); + setupHomeSearchToolbarWithSortAndListButtons(); + updateActionBarTitleAndHomeButton(null); } else if (menuItemId == R.id.nav_favorites) { openFavoritesTab(); } else if (menuItemId == R.id.nav_assistant && !(this instanceof ComposeActivity)) { @@ -706,8 +710,7 @@ private void resetFileDepth() { } } - protected void openSharedTab() { - resetFileDepth(); + private void openSharedTab() { resetOnlyPersonalAndOnDevice(); SearchEvent searchEvent = new SearchEvent("", SearchRemoteOperation.SearchType.SHARED_FILTER); launchActivityForSearch(searchEvent, R.id.nav_shared); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index cc27ee6c40d7..c599740c49fb 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -2767,7 +2767,6 @@ class FileDisplayActivity : listOfFilesFragment?.setCurrentSearchType(event) updateActionBarTitleAndHomeButton(null) - // listOfFilesFragment?.setActionBarTitle() } @Subscribe(threadMode = ThreadMode.MAIN) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt index a7c5aae95e72..96e61a9dd5ea 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt @@ -765,9 +765,13 @@ open class ExtendedListFragment : } } - protected fun setGridSwitchButton() { + protected fun setLayoutSwitchButton() { + setLayoutSwitchButton(isGridEnabled) + } + + protected fun setLayoutSwitchButton(isGrid: Boolean) { mSwitchGridViewButton?.let { - if (isGridEnabled) { + if (isGrid) { it.setContentDescription(getString(R.string.action_switch_list_view)) it.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_view_list) } else { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java index 26b3f8de3d6a..14d5cbc9cf6e 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java @@ -134,7 +134,7 @@ public void onActivityCreated(Bundle savedInstanceState) { } } - setGridSwitchButton(); + setLayoutSwitchButton(); if (mSwitchGridViewButton != null) { mSwitchGridViewButton.setOnClickListener(v -> { @@ -143,7 +143,7 @@ public void onActivityCreated(Bundle savedInstanceState) { } else { switchToGridView(); } - setGridSwitchButton(); + setLayoutSwitchButton(); }); } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index df4726988db8..cfe5f17f3f57 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -435,7 +435,7 @@ public void onActivityCreated(Bundle savedInstanceState) { } else { setGridAsPreferred(); } - setGridSwitchButton(); + setLayoutSwitchButton(); }); } @@ -1573,17 +1573,9 @@ public void updateOCFile(@NonNull OCFile file) { } private void updateLayout() { - // decide grid vs list view - if (isGridViewPreferred(mFile)) { - switchToGridView(); - } else { - switchToListView(); - } - + setLayoutViewMode(); updateSortButton(); - if (mSwitchGridViewButton != null) { - setGridSwitchButton(); - } + setLayoutSwitchButton(); setFabVisible(!mHideFab); slideHideBottomBehaviourForBottomNavigationView(!mHideFab); @@ -1619,14 +1611,34 @@ public void sortFiles(FileSortOrder sortOrder) { } /** - * Determines if user set folder to grid or list view. If folder is not set itself, it finds a parent that is set - * (at least root is set). + * Determines whether a folder should be displayed in grid or list view. + *

+ * The preference is checked for the given folder. If the folder itself does not have a preference set, + * it will fall back to its parent folder recursively until a preference is found (root folder is always set). + * Additionally, if a search event is active and is of type {@code SHARED_FILTER}, grid view is disabled. * - * @param folder Folder to check or null for root folder - * @return 'true' is folder should be shown in grid mode, 'false' if list mode is preferred. + * @param folder The folder to check, or {@code null} to refer to the root folder. + * @return {@code true} if the folder should be displayed in grid mode, {@code false} if list mode is preferred. */ - public boolean isGridViewPreferred(@Nullable OCFile folder) { - return FOLDER_LAYOUT_GRID.equals(preferences.getFolderLayout(folder)); + private boolean isGridViewPreferred(@Nullable OCFile folder) { + if (searchEvent != null) { + return (searchEvent.toSearchType() != SHARED_FILTER) && + FOLDER_LAYOUT_GRID.equals(preferences.getFolderLayout(folder)); + } else { + return FOLDER_LAYOUT_GRID.equals(preferences.getFolderLayout(folder)); + } + } + + private void setLayoutViewMode() { + boolean isGrid = isGridViewPreferred(mFile); + + if (isGrid) { + switchToGridView(); + } else { + switchToListView(); + } + + setLayoutSwitchButton(isGrid); } public void setListAsPreferred() { @@ -1857,11 +1869,7 @@ protected void handleSearchEvent(SearchEvent event) { new Handler(Looper.getMainLooper()).post(() -> { updateSortButton(); - if (isGridViewPreferred(mFile) && !isGridEnabled()) { - switchToGridView(); - } else if (!isGridViewPreferred(mFile) && isGridEnabled()) { - switchToListView(); - } + setLayoutViewMode(); }); final User currentUser = accountManager.getUser(); From d3cafefd97c23cb7063a59dd7d829462aeda74e1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 19 Jan 2026 12:08:45 +0100 Subject: [PATCH 099/125] fix(local-file-list-adapter): navigation Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../android/ui/adapter/LocalFileListAdapter.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java index ddcf18e3f3da..676f911e6f49 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java @@ -371,8 +371,6 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int public void swapDirectory(final File directory) { localFileListFragmentInterface.setLoading(true); currentOffset = 0; - mFiles.clear(); - mFilesAll.clear(); singleThreadExecutor.execute(() -> { // Load first page of folders @@ -398,8 +396,10 @@ public void swapDirectory(final File directory) { @SuppressLint("NotifyDataSetChanged") private void updateUIForFirstPage(List firstPage) { new Handler(Looper.getMainLooper()).post(() -> { - mFiles = new ArrayList<>(firstPage); - mFilesAll = new ArrayList<>(firstPage); + mFiles.clear(); + mFilesAll.clear(); + mFiles.addAll(firstPage); + mFilesAll.addAll(firstPage); notifyDataSetChanged(); localFileListFragmentInterface.setLoading(false); }); @@ -432,15 +432,16 @@ private void loadRemainingEntries(File directory, boolean fetchFolders) { private void notifyItemRange(List updatedList) { new Handler(Looper.getMainLooper()).post(() -> { - int from = mFiles.size(); - int to = updatedList.size(); + int headerOffset = shouldShowHeader() ? 1 : 0; + int startPositionInAdapter = mFiles.size() + headerOffset; + int itemCount = updatedList.size(); mFiles.addAll(updatedList); mFilesAll.addAll(updatedList); Log_OC.d(TAG, "notifyItemRange, item size: " + mFilesAll.size()); - notifyItemRangeInserted(from, to); + notifyItemRangeInserted(startPositionInAdapter, itemCount); }); } From 74c15ab4092f02f9192919860de3965f129a1958 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 21 Jan 2026 08:43:02 +0100 Subject: [PATCH 100/125] chore: java 21 Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .devcontainer/Dockerfile | 2 +- .github/workflows/analysis.yml | 4 ++-- .github/workflows/assembleFlavors.yml | 2 +- .github/workflows/check.yml | 4 ++-- .github/workflows/codeql.yml | 4 ++-- .github/workflows/detectWrongSettings.yml | 4 ++-- .github/workflows/qa.yml | 4 ++-- .github/workflows/unit-tests.yml | 4 ++-- app/build.gradle.kts | 6 +++--- appscan/build.gradle.kts | 6 +++--- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f0a234dab681..1b2e5424ad70 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,7 +4,7 @@ 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 apt-get install -y unzip wget openjdk-21-jdk vim RUN wget https://dl.google.com/android/repository/commandlinetools-linux-6858069_latest.zip -O /tmp/commandlinetools.zip RUN cd /tmp && unzip commandlinetools.zip diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 242d1db551e6..3a374918f603 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -56,11 +56,11 @@ jobs: persist-credentials: false repository: ${{ steps.get-vars.outputs.repo }} ref: ${{ steps.get-vars.outputs.branch }} - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Install dependencies run: | sudo apt install python3-defusedxml diff --git a/.github/workflows/assembleFlavors.yml b/.github/workflows/assembleFlavors.yml index 45cefe72707d..cf4d65ac5ab6 100644 --- a/.github/workflows/assembleFlavors.yml +++ b/.github/workflows/assembleFlavors.yml @@ -23,7 +23,7 @@ jobs: flavor: [ Generic, Gplay, Huawei ] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: set up JDK 17 + - name: set up JDK 21 uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 with: distribution: "temurin" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7c3b3780937e..075970a39c68 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,10 +23,10 @@ jobs: task: [ detekt, spotlessKotlinCheck, lint ] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Check ${{ matrix.task }} run: ./gradlew ${{ matrix.task }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b61cee53f5d6..a700d0e45e51 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,11 +46,11 @@ jobs: uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: languages: ${{ matrix.language }} - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Assemble run: | mkdir -p "$HOME/.gradle" diff --git a/.github/workflows/detectWrongSettings.yml b/.github/workflows/detectWrongSettings.yml index 72e26e5b4130..6e0d4fe8a0de 100644 --- a/.github/workflows/detectWrongSettings.yml +++ b/.github/workflows/detectWrongSettings.yml @@ -21,10 +21,10 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Detect SNAPSHOT run: scripts/analysis/detectWrongSettings.sh diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 21c6b202a11f..c493776642c4 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -29,12 +29,12 @@ jobs: with: persist-credentials: false - - name: set up JDK 17 + - name: set up JDK 21 uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 if: ${{ steps.check-secrets.outputs.ok == 'true' }} with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Build QA if: ${{ steps.check-secrets.outputs.ok == 'true' }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 53039642bedd..e052459a5fc3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -24,11 +24,11 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Delete old comments env: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 613ec1b335d9..ef2466fd2d2b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -198,8 +198,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } lint { @@ -240,7 +240,7 @@ kapt.useBuildCache = true ksp.arg("room.schemaLocation", "$projectDir/schemas") -kotlin.compilerOptions.jvmTarget.set(JvmTarget.JVM_17) +kotlin.compilerOptions.jvmTarget.set(JvmTarget.JVM_21) spotless.kotlin { target("**/*.kt") diff --git a/appscan/build.gradle.kts b/appscan/build.gradle.kts index f16887151766..d650a922c963 100644 --- a/appscan/build.gradle.kts +++ b/appscan/build.gradle.kts @@ -27,8 +27,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } lint.targetSdk = 36 @@ -36,7 +36,7 @@ android { } kotlin.compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_21) freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") } From 36e1ce97a77384124a42f560c271ee63aa59e475 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 21 Jan 2026 08:44:39 +0100 Subject: [PATCH 101/125] chore: java 21 Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .github/workflows/assembleFlavors.yml | 2 +- .github/workflows/screenShotTest.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/assembleFlavors.yml b/.github/workflows/assembleFlavors.yml index cf4d65ac5ab6..cbf736dc0049 100644 --- a/.github/workflows/assembleFlavors.yml +++ b/.github/workflows/assembleFlavors.yml @@ -27,7 +27,7 @@ jobs: uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 with: distribution: "temurin" - java-version: 17 + java-version: 21 - uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 - name: Build ${{ matrix.flavor }} run: | diff --git a/.github/workflows/screenShotTest.yml b/.github/workflows/screenShotTest.yml index f04a534b2c61..b82516fc0b6a 100644 --- a/.github/workflows/screenShotTest.yml +++ b/.github/workflows/screenShotTest.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Enable KVM group perms run: | From 140c4334ad55573b781b22297afc4a9fd3b92d00 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 20 Jan 2026 13:23:03 +0100 Subject: [PATCH 102/125] feat(assistant-screen): PROCESS_TEXT support Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- app/src/main/AndroidManifest.xml | 9 ++++- .../client/assistant/AssistantScreen.kt | 34 +++++++++++++++---- .../client/assistant/model/AssistantPage.kt | 13 +++++++ .../ui/composeActivity/ComposeActivity.kt | 25 ++++++++++++-- .../ui/composeActivity/ComposeViewModel.kt | 24 +++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/assistant/model/AssistantPage.kt create mode 100644 app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b9589dd3854c..ee6e7fd2bc80 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -135,7 +135,14 @@ + android:exported="true" + android:label="@string/compose_activity_label"> + + + + + + when (page) { - 0 -> { + AssistantPage.Conversation.id -> { ConversationScreen(viewModel = conversationViewModel, close = { scope.launch { - pagerState.scrollToPage(1) + pagerState.scrollToPage(AssistantPage.Content.id) } }, openChat = { newSessionId -> viewModel.initSessionId(newSessionId) @@ -138,11 +158,11 @@ fun AssistantScreen( viewModel.selectTaskType(chatTaskType) } scope.launch { - pagerState.scrollToPage(1) + pagerState.scrollToPage(AssistantPage.Content.id) } }) } - 1 -> { + AssistantPage.Content.id -> { Scaffold( modifier = Modifier.pullToRefresh( false, @@ -166,7 +186,7 @@ fun AssistantScreen( viewModel.selectTaskType(task) }, navigateToConversationList = { scope.launch { - pagerState.scrollToPage(0) + pagerState.scrollToPage(AssistantPage.Conversation.id) } }) } @@ -413,6 +433,7 @@ private fun AssistantScreenPreview() { MaterialTheme( content = { AssistantScreen( + composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(false), activity = ComposeActivity(), @@ -431,6 +452,7 @@ private fun AssistantEmptyScreenPreview() { MaterialTheme( content = { AssistantScreen( + composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(true), activity = ComposeActivity(), diff --git a/app/src/main/java/com/nextcloud/client/assistant/model/AssistantPage.kt b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantPage.kt new file mode 100644 index 000000000000..5fd71ae1073a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantPage.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.model + +enum class AssistantPage(val id: Int) { + Conversation(0), + Content(1) +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt index 1428ab363b76..d7d1c0a75bc4 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt @@ -7,9 +7,11 @@ */ package com.nextcloud.ui.composeActivity +import android.content.Intent import android.os.Bundle import android.view.MenuItem import android.view.View +import androidx.activity.viewModels import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,6 +37,7 @@ import com.owncloud.android.ui.activity.DrawerActivity class ComposeActivity : DrawerActivity() { lateinit var binding: ActivityComposeBinding + private val composeViewModel: ComposeViewModel by viewModels() companion object { const val DESTINATION = "DESTINATION" @@ -46,9 +49,8 @@ class ComposeActivity : DrawerActivity() { setContentView(binding.root) val destination = - intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) ?: throw IllegalArgumentException( - "destination is not exists" - ) + intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) + ?: ComposeDestination.getAssistantScreen(this) setupActivityUIFor(destination) @@ -60,6 +62,22 @@ class ComposeActivity : DrawerActivity() { } ) } + + processText(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + processText(intent) + } + + private fun processText(intent: Intent) { + val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) + if (text.isNullOrEmpty()) { + return + } + + composeViewModel.updateSelectedText(text.toString()) } private fun setupActivityUIFor(destination: ComposeDestination) { @@ -105,6 +123,7 @@ class ComposeActivity : DrawerActivity() { val client = nextcloudClient ?: return AssistantScreen( + composeViewModel = composeViewModel, viewModel = AssistantViewModel( accountName = userAccountManager.user.accountName, remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities), diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt new file mode 100644 index 000000000000..86b94a633cd8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.composeActivity + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class ComposeViewModel : ViewModel() { + private val _selectedText = MutableStateFlow(null) + val selectedText: StateFlow = _selectedText + + fun updateSelectedText(value: String) { + _selectedText.update { + value + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c532cd7ed6f..c373ae1d27dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,8 @@ Failed to delete conversation Delete conversation + Nextcloud Assistant + Text copied from another app Output shown here is generated by AI. Make sure to always double-check. From ed2e505ca9533d89dc56ac3b7cbd0665f4adf13b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 21 Jan 2026 11:10:37 +0100 Subject: [PATCH 103/125] copy selected text to input bar Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/assistant/AssistantScreen.kt | 34 +++++++++---------- .../client/assistant/AssistantViewModel.kt | 9 +++++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index dc84e333060b..1739cfe494eb 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -41,10 +41,8 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -60,8 +58,8 @@ import com.nextcloud.client.assistant.conversation.ConversationScreen import com.nextcloud.client.assistant.conversation.ConversationViewModel import com.nextcloud.client.assistant.conversation.repository.MockConversationRemoteRepository import com.nextcloud.client.assistant.extensions.getInputTitle -import com.nextcloud.client.assistant.model.AssistantScreenState import com.nextcloud.client.assistant.model.AssistantPage +import com.nextcloud.client.assistant.model.AssistantScreenState import com.nextcloud.client.assistant.model.ScreenOverlayState import com.nextcloud.client.assistant.repository.local.MockAssistantLocalRepository import com.nextcloud.client.assistant.repository.remote.MockAssistantRemoteRepository @@ -76,7 +74,6 @@ 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 com.owncloud.android.utils.ClipboardUtil import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -115,17 +112,18 @@ fun AssistantScreen( } LaunchedEffect(selectedText) { - if (selectedText.isNullOrEmpty()) { - return@LaunchedEffect - } - - if (pagerState.currentPage == AssistantPage.Conversation.id) { - pagerState.scrollToPage(AssistantPage.Content.id) - } + selectedText?.let { + if (it.isBlank()) { + return@LaunchedEffect + } - ClipboardUtil.copyToClipboard(activity, selectedText, false) + if (pagerState.currentPage == AssistantPage.Conversation.id) { + pagerState.scrollToPage(AssistantPage.Content.id) + } - snackbarHostState.showSnackbar(activity.getString(R.string.assistant_screen_text_selected)) + viewModel.updateInputBarText(it) + snackbarHostState.showSnackbar(activity.getString(R.string.assistant_screen_text_selected)) + } } LaunchedEffect(sessionId) { @@ -193,7 +191,7 @@ fun AssistantScreen( }, bottomBar = { if (!taskTypes.isNullOrEmpty()) { - ChatInputBar( + InputBar( sessionId, selectedTaskType, viewModel @@ -253,9 +251,9 @@ fun AssistantScreen( @Suppress("LongMethod") @Composable -private fun ChatInputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) { +private fun InputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) { val scope = rememberCoroutineScope() - var text by remember { mutableStateOf("") } + val text by viewModel.inputBarText.collectAsState() Surface( tonalElevation = 3.dp, @@ -284,7 +282,7 @@ private fun ChatInputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, view ) { OutlinedTextField( value = text, - onValueChange = { text = it }, + onValueChange = { viewModel.updateInputBarText(it) }, modifier = Modifier .weight(1f) .padding(end = 8.dp), @@ -311,7 +309,7 @@ private fun ChatInputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, view scope.launch { delay(CHAT_INPUT_DELAY) - text = "" + viewModel.updateInputBarText("") } } ) { diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt index 6e49c5adfa79..820bbafb63e7 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt @@ -44,6 +44,9 @@ class AssistantViewModel( private const val POLLING_INTERVAL_MS = 15_000L } + private val _inputBarText = MutableStateFlow("") + val inputBarText: StateFlow = _inputBarText + private val _screenState = MutableStateFlow(null) val screenState: StateFlow = _screenState @@ -314,6 +317,12 @@ class AssistantViewModel( } } + fun updateInputBarText(value: String) { + _inputBarText.update { + value + } + } + private fun removeTaskFromList(id: Long) { _filteredTaskList.update { currentList -> currentList?.filter { it.id != id } From 4314b4057dc7fecdee2b366ec2d5d6b8f805e50a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 21 Jan 2026 09:09:49 +0100 Subject: [PATCH 104/125] Rename .java to .kt Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- ...hemeableSwitchPreference.java => ThemeableSwitchPreference.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/owncloud/android/ui/{ThemeableSwitchPreference.java => ThemeableSwitchPreference.kt} (100%) diff --git a/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.java b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt similarity index 100% rename from app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.java rename to app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt From 04dde3960fa7820f5c1e83528fb7a852d9103ef0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 21 Jan 2026 09:09:50 +0100 Subject: [PATCH 105/125] feat(settings-activity): m3 switch Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../android/ui/ThemeableSwitchPreference.kt | 88 +++++++------------ app/src/main/res/layout/themeable_switch.xml | 14 +++ 2 files changed, 48 insertions(+), 54 deletions(-) create mode 100644 app/src/main/res/layout/themeable_switch.xml diff --git a/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt index 279529622c43..7ab0c7978b6b 100644 --- a/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt +++ b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt @@ -1,67 +1,47 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2017 Tobias Kaminsky - * SPDX-FileCopyrightText: 2017 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.ui; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.preference.SwitchPreference; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Switch; - -import com.owncloud.android.MainApp; -import com.owncloud.android.utils.theme.ViewThemeUtils; - -import javax.inject.Inject; - -/** - * Themeable switch preference TODO Migrate to androidx - */ -public class ThemeableSwitchPreference extends SwitchPreference { +package com.owncloud.android.ui + +import android.content.Context +import android.preference.SwitchPreference +import android.util.AttributeSet +import android.view.View +import com.google.android.material.materialswitch.MaterialSwitch +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +@Suppress("DEPRECATION") +class ThemeableSwitchPreference : SwitchPreference { @Inject - ViewThemeUtils viewThemeUtils; + lateinit var viewThemeUtils: ViewThemeUtils - public ThemeableSwitchPreference(Context context) { - super(context); - MainApp.getAppComponent().inject(this); + /** + * Do not delete constructor. These are used. + */ + constructor(context: Context) : super(context) { + init() } - public ThemeableSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - MainApp.getAppComponent().inject(this); + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { + init() } - public ThemeableSwitchPreference(Context context, AttributeSet attrs) { - super(context, attrs); - MainApp.getAppComponent().inject(this); + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + init() } - @Override - protected void onBindView(View view) { - super.onBindView(view); - - if (view instanceof ViewGroup) { - findSwitch((ViewGroup) view); - } + private fun init() { + MainApp.getAppComponent().inject(this) + setWidgetLayoutResource(R.layout.themeable_switch) } - private void findSwitch(ViewGroup viewGroup) { - for (int i = 0; i < viewGroup.getChildCount(); i++) { - View child = viewGroup.getChildAt(i); - - if (child instanceof @SuppressLint("UseSwitchCompatOrMaterialCode") Switch switchView) { - viewThemeUtils.platform.colorSwitch(switchView); - - break; - } else if (child instanceof ViewGroup) { - findSwitch((ViewGroup) child); - } + @Deprecated("Deprecated in Java") + override fun onBindView(view: View) { + super.onBindView(view) + val checkable = view.findViewById(R.id.switch_widget) + if (checkable is MaterialSwitch) { + checkable.setChecked(isChecked) + viewThemeUtils.material.colorMaterialSwitch(checkable) } } } diff --git a/app/src/main/res/layout/themeable_switch.xml b/app/src/main/res/layout/themeable_switch.xml new file mode 100644 index 000000000000..f16d35852f63 --- /dev/null +++ b/app/src/main/res/layout/themeable_switch.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file From d0ec004823142f88ee0671e023b92f27d07fd2c0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 21 Jan 2026 09:15:07 +0100 Subject: [PATCH 106/125] add license Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../com/owncloud/android/ui/ThemeableSwitchPreference.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt index 7ab0c7978b6b..fa8ccbb332be 100644 --- a/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt +++ b/app/src/main/java/com/owncloud/android/ui/ThemeableSwitchPreference.kt @@ -1,3 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + package com.owncloud.android.ui import android.content.Context From 4f9b2d18de2682df732c89178d63beaa0b296643 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 21 Jan 2026 21:56:21 +0100 Subject: [PATCH 107/125] style: Fix formatting Signed-off-by: Andy Scherzinger Signed-off-by: Raphael Vieira --- app/src/main/res/layout/themeable_switch.xml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/layout/themeable_switch.xml b/app/src/main/res/layout/themeable_switch.xml index f16d35852f63..a83754d18a2d 100644 --- a/app/src/main/res/layout/themeable_switch.xml +++ b/app/src/main/res/layout/themeable_switch.xml @@ -1,14 +1,14 @@ - - \ No newline at end of file + android:focusable="false" /> From e51cc23e950e08a9b3fbadded775741c3846402f Mon Sep 17 00:00:00 2001 From: Nextcloud bot Date: Thu, 22 Jan 2026 02:57:24 +0000 Subject: [PATCH 108/125] fix(l10n): Update translations from Transifex Signed-off-by: Nextcloud bot Signed-off-by: Raphael Vieira --- app/src/main/res/values-b+en+001/strings.xml | 1 + app/src/main/res/values-cs-rCZ/strings.xml | 1 + app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rAR/strings.xml | 1 + app/src/main/res/values-es-rMX/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-et-rEE/strings.xml | 1 + app/src/main/res/values-eu/strings.xml | 1 + app/src/main/res/values-fa/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 6 +++++- app/src/main/res/values-ga/strings.xml | 1 + app/src/main/res/values-gl/strings.xml | 1 + app/src/main/res/values-hu-rHU/strings.xml | 1 + app/src/main/res/values-is/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-ka/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-lo/strings.xml | 1 + app/src/main/res/values-nb-rNO/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-sk-rSK/strings.xml | 1 + app/src/main/res/values-sr/strings.xml | 1 + app/src/main/res/values-sv/strings.xml | 1 + app/src/main/res/values-sw/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-ug/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-vi/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rHK/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + 38 files changed, 42 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-b+en+001/strings.xml b/app/src/main/res/values-b+en+001/strings.xml index 9a3f66162722..d681b96eb4aa 100644 --- a/app/src/main/res/values-b+en+001/strings.xml +++ b/app/src/main/res/values-b+en+001/strings.xml @@ -188,6 +188,7 @@ Found a bug? Oddments? Help by testing Report an issue on GitHub + Nextcloud Assistant Configure Remove local encryption Do you really want to delete %1$s? diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index 1067beb14858..f366a2644edd 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -187,6 +187,7 @@ Našli jste chybu? Něco podivného? Pomozte testováním Nahlásit problém prostřednictvím portálu Github + Nextcloud Asistent Nastavit Odebrat lokální šifrování Opravdu chcete %1$s odstranit? diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index a19c710c8ade..18a9c0639f9d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -183,6 +183,7 @@ Fundet en fejl? Mærkværdighed? Hjælp med at test Rapportér et problem på Github + Nextcloud assistent Konfigurer Fjern lokal kryptering Er du sikker på at du vil slette %1$s ? diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 87349b5b16c7..04fa10cfa4bc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -188,6 +188,7 @@ Fehler gefunden? Komisches Verhalten? Helfen Sie durch Testen Fehlerbericht auf GitHub erstellen + Nextcloud Assistant Konfigurieren Lokale Verschlüsselung entfernen Wollen Sie %1$s wirklich löschen? diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index a417c89f7352..44b52de654a9 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -151,6 +151,7 @@ Βρήκατε σφάλμα; Κάτι σας φαίνεται παράξενο; Βοήθησε δοκιμάζοντας Ανέφερε ένα ζήτημα στο GitHub + Βοηθός Nextcloud Ρύθμιση Αφαίρεση τοπικής κρυπτογράφησης Θέλετε σίγουρα να διαγράψετε το %1$s; diff --git a/app/src/main/res/values-es-rAR/strings.xml b/app/src/main/res/values-es-rAR/strings.xml index 9cb4c0b344b1..7e0a93affc7d 100644 --- a/app/src/main/res/values-es-rAR/strings.xml +++ b/app/src/main/res/values-es-rAR/strings.xml @@ -155,6 +155,7 @@ ¿Encontró una falla? ¿Hay algo raro? Ayúdenos probando Reportar un error en GitHub + Asistente de Nextcloud Configurar Eliminar encriptación local ¿Realmente quieres eliminar %1$s? diff --git a/app/src/main/res/values-es-rMX/strings.xml b/app/src/main/res/values-es-rMX/strings.xml index 1c5f08daee8e..97d337bb1740 100644 --- a/app/src/main/res/values-es-rMX/strings.xml +++ b/app/src/main/res/values-es-rMX/strings.xml @@ -160,6 +160,7 @@ ¿Encontraste una falla? ¿Hay algo raro? Ayúdanos probando Reporta un problema en GitHub + Asistente de Nextcloud Configurar Eliminar el cifrado local ¿Realmente deseas elminiar %1$s? diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2be13fec8ec9..43fb50917907 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -180,6 +180,7 @@ ¿Encontraste un error? ¿Algo va mal? Ayúdanos a realizar pruebas Informar de un problema en GitHub + Asistente de Nextcloud Configurar Eliminar cifrado local ¿Estás seguro de que quieres eliminar %1$s? diff --git a/app/src/main/res/values-et-rEE/strings.xml b/app/src/main/res/values-et-rEE/strings.xml index 66099a30074b..40326234ffa1 100644 --- a/app/src/main/res/values-et-rEE/strings.xml +++ b/app/src/main/res/values-et-rEE/strings.xml @@ -188,6 +188,7 @@ Leidsid vea? Midagi veidrat? Aita testimisega Teavita probleemist GitHubis + Nextcloudi Abiline Seadista Eemalda kohalik krüptimine Oled sa kindel, et soovid %1$s kustutada? diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 045bfb467c90..ad90a6147185 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -174,6 +174,7 @@ Errore bat topatu duzu? Gauza arraroren bat? Lagundu probatzen Jakinarazi arazoa GitHub-en + Nextcloud Assistant Konfiguratu Kendu enkriptatze lokala Ziur zaude %1$s ezabatu nahi duzula? diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index f5cf5ff960aa..080085035bec 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -149,6 +149,7 @@ خطایی پیدا کردید؟ عجیب و غریب؟ با آزمایش کردن کمک کنید گزارش یک مورد در GitHub + Nextcloud Assistant " پیکربندی" رمزگذاری محلی را حذف کنید. آیا واقعا می‌خواهید %1$s را حذف کنید؟ diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 655283b27198..67e204d36317 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -167,6 +167,7 @@ Löysitkö bugin tai jotain muuta outoa? Auta testaamalla Ilmoita ongelmasta GitHubissa + Nextcloud-avustaja Asetukset Poista paikallinen salaus Haluatko varmasti poistaa kohteen %1$s? diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2f0dcd7b1026..00ae9a9891a7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -18,7 +18,7 @@ Déplacer ou copier Ouvrir avec Rechercher - Propriétés + Détails Envoyer Paramètres Trier @@ -187,6 +187,7 @@ Vous avez trouvé un bug ? Quelque chose vous semble étrange ? Aidez en réalisant des tests Signaler un problème sur Github + Assistant Nextcloud Configurer Retirer le chiffrement local Souhaitez-vous vraiment supprimer %1$s ? @@ -753,6 +754,7 @@ Intervalle Gestion les dossiers internes pour une synchronisation bidirectionnelle Activer la synchronisation bidirectionnelle + Synchronisation bidirectionnelle Sombre Clair Selon le système @@ -957,6 +959,8 @@ Suggérer Synchroniser Synchroniser quand même + Résoudre les conflits + Conflits de téléversement de fichiers Des conflits ont été trouvés Le dossier %1$s n\'existe plus Duplication de synchronisation diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index b0e8d6725bb0..29e68f2e220d 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -188,6 +188,7 @@ Aimsíodh fabht? Oddments? Cabhair trí thástáil Tuairiscigh saincheist ar GitHub + Cúntóir Nextcloud Cumraigh Bain criptiú áitiúil An bhfuil tú cinnte gur mhaith leat %1$s a scriosadh? diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index ad61a89c1813..8c8973680b3d 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -188,6 +188,7 @@ Atopou un fallo? hai algo estraño? Axúdenos facendo probas Informe dun incidente no GitHub + Asistente de Nextcloud Configurar Retirar a cifraxe local Confirma que quere eliminar %1$s? diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index c6203332fb13..50628278fc08 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -178,6 +178,7 @@ Hibát talált? Vagy furcsaságot? Segítsen a teszteléssel Jelentse az esetet a GitHubon + Nextcloud Asszisztens Beállítás Helyi titkosítás eltávolítása Biztos, hogy törli ezt: %1$s? diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index f9d6d97d571c..7d00f2a8382b 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -178,6 +178,7 @@ Fannstu villu? Skringilegheit? Hjálpaðu til við prófanir Tilkynntu um vandamál á GitHub + Nextcloud Assistant meðhjálpari Stilla Fjarlægja staðværa dulritun Viltu virkilega eyða \'%s\'? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e64b3279950a..3d50a673a13e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -187,6 +187,7 @@ Trovato un bug? Stranezze? Aiutaci nella fase di test Segnala un problema su GitHub + Assistente di Nextcloud Configura Rimuovi la cifratura locale Vuoi davvero eliminare %1$s? diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 62f713d214b4..75830dbe0a65 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -183,6 +183,7 @@ バグがありましたか? 問題がありますか? テストによるヘルプ Githubでエラーを報告する + Nextcloud アシスタント 構成 ローカル暗号化を解除 本当に %1$s を削除しますか? diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 4fc80eafd51c..c0e037ea9506 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -141,6 +141,7 @@ Found a bug? Oddments? Help by testing Report an issue on GitHub + Nextcloud-ის ასისტენტი Configure Remove local encryption Do you really want to delete %1$s? diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9f38f57cd7bc..7eabe4c6be67 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -175,6 +175,7 @@ 버그를 찾으셨나요? 무언가 이상하게 작동하나요? 테스트로 돕기 GitHub에 문제점 보고하기 + Nextcloud 어시스턴트 구성 로컬 암호화를 제거합니다. %1$s을(를) 삭제하시겠습니까? diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index 17c5157c1fb0..edda755b22b4 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -184,6 +184,7 @@ ພົບຂໍ້ຜິດພາດບໍ? ການຊ່ວຍເຫຼືອ ໂດຍການທົດສອບ ລາຍງານບັນຫາກ່ຽວກັບ GitHub + ຜູ້ຊ່ວຍ Nextcloud ຕັ້ງຄ່າຄອຍຟິກ Remove local encryption ທ່ານຕ້ອງການລືບແທ້ບໍ %1$s? diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 8014ae5336d2..3be4c128cf51 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -170,6 +170,7 @@ Funnet en feil? Føles noe rart? Hjelp oss å teste Rapporter en feil på GitHub + Nextcloud-assistent Konfigurer Fjern lokal kryptering Ønsker du virkelig å slette %1$s? diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index df3672b7d4fa..9594f2169040 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -173,6 +173,7 @@ Bug gevonden? Rare dingen? Help bij testen Meld een probleem op GitHub + Nextcloud Assistent Configureren Verwijder lokale encryptie Wil je %1$s echt verwijderen? diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4389da1343d7..4bcc5b6f66a5 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -187,6 +187,7 @@ Znalazłeś błąd? Jest coś, co chciałbyś poprawić? Pomóż nam testować Zgłoś problem na GitHubie + Asystent Nextcloud Konfiguruj Usuń szyfrowanie lokalne Czy na pewno chcesz usunąć %1$s? diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7604db378a9a..45f8ea81c91b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -188,6 +188,7 @@ Encontrou um erro? Algo estranho? Ajude testando Reporte um problema no GitHub + Nextcloud Assistente Configurar Remover criptografia local Quer realmente excluir %1$s? diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5202dde89206..ed31df597ecc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -183,6 +183,7 @@ Нашли ошибку? Заметили необычное поведение программы? Помогите нам в тестировании Сообщить о проблеме на GitHub + Помощник Nextcloud Настроить Отключить шифрование на устройстве Действительно удалить «%1$s»? diff --git a/app/src/main/res/values-sk-rSK/strings.xml b/app/src/main/res/values-sk-rSK/strings.xml index 88804de08dc6..645fc118af52 100644 --- a/app/src/main/res/values-sk-rSK/strings.xml +++ b/app/src/main/res/values-sk-rSK/strings.xml @@ -187,6 +187,7 @@ Našli ste chybu? Niečo nefunguje? Pomôžte s testovaním Nahlásiť chybu na Githube + Nextcloud Asistent Nastaviť Odstrániť lokálne šifrovanie Naozaj chcete odstrániť %1$s? diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index b93c136ef684..0b64b9739f48 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -183,6 +183,7 @@ Нашли сте грешку? Нешто чудно? Помози тестирајући Пријавите проблем на Гитхабу + Nextcloud Асистент Подеси Уклони локално шифровање Заиста желите да обришете %1$s? diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c18f97429ab6..b81425904601 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -187,6 +187,7 @@ Hittat en bugg? Något som är konstigt? Hjälp till att testa Rapportera ditt problem på GitHub + Nextcloud Assistant Konfigurera Ta bort lokal kryptering Vill du verkligen ta bort %1$s? diff --git a/app/src/main/res/values-sw/strings.xml b/app/src/main/res/values-sw/strings.xml index da57a8353da9..0147fc04dd32 100644 --- a/app/src/main/res/values-sw/strings.xml +++ b/app/src/main/res/values-sw/strings.xml @@ -187,6 +187,7 @@ Je, umepata mdudu? Odds? Msaada kwa kupima Ripoti suala kwenye GitHub + Msaidizi wa Nextcloud Sanidi Ondoa usimbaji fiche wa ndani Je, kweli unataka kufuta %1$s? diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 32d8900a25a1..cc937d517c31 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -188,6 +188,7 @@ Bir hata mı buldunuz? Bir gariplik mi var? Deneyerek bize yardımcı olun GitHub üzerinden sorun bildirin + Nextcloud Yardımcısı Yapılandır Yerel şifrelemeyi kaldır %1$s dosyasını silmek istediğinize emin misiniz? diff --git a/app/src/main/res/values-ug/strings.xml b/app/src/main/res/values-ug/strings.xml index 0814f9a807b9..31c4836238e3 100644 --- a/app/src/main/res/values-ug/strings.xml +++ b/app/src/main/res/values-ug/strings.xml @@ -187,6 +187,7 @@ خاتالىق بايقالدى؟ قالدۇق؟ سىناق ئارقىلىق ياردەم GitHub دىكى بىر مەسىلىنى دوكلات قىلىڭ + Nextcloud ياردەمچىسى سەپلەڭ يەرلىك مەخپىيلەشتۈرۈشنى ئۆچۈرۈڭ راستىنلا%1$s نى ئۆچۈرمەكچىمۇ؟ diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f3e5bd65caf3..6b1de22684f1 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -188,6 +188,7 @@ Помітили ваду чи помилку? Допомогти з тестуванням Повідомити про помилку на GitHub + Асистент Nextcloud Налаштування Вилучити шифрування на пристрої Ви справді бажаєте вилучити %1$s? diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index ad0631fc917b..d45faac11611 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -140,6 +140,7 @@ Tìm thấy một lỗi? Giúp bằng cách thử Báo cáo vấn đề trên GitHub + Trợ lý Nextcloud Thiết lập Bạn thực sự muốn xóa %1$s? Bạn thực sự muốn xóa mục lựa chọn này? diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 744a36118c34..b32d6f299561 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -187,6 +187,7 @@ 发现错误?细节? 通过测试帮助 在Github报告问题 OR 通过Github提交问题(issue) + Nextcloud助手 用户配置 移除近端加密 确定删除%1$s? diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 86f8090effec..f19580c4263c 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -188,6 +188,7 @@ 發現問題或瑕疵? 協助測試 在 GitHub 上舉報問題 + Nextcloud 小幫手 設定 移除近端加密 你真的想要刪除 %1$s? diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0d422c50da2d..41e2b24f983a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -188,6 +188,7 @@ 發現問題或瑕疵? 協助測試 在 GitHub 上回報問題 + Nextcloud 小幫手 設定 移除本機加密 您真的想要刪除 %1$s 嗎? From da7e7e295c2bf7ee03dbcf4e97c6957eda35191e Mon Sep 17 00:00:00 2001 From: alperozturk Date: Mon, 12 Jan 2026 12:29:43 +0100 Subject: [PATCH 109/125] fix: auto upload file skip check Signed-off-by: alperozturk Signed-off-by: Raphael Vieira --- .../jobs/autoUpload/FileSystemRepository.kt | 21 +++++++++---------- .../extensions/SyncedFolderExtensions.kt | 21 +++++++++++++------ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt index 9ff924c6aadf..f9c12b36b9a1 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt @@ -159,20 +159,19 @@ class FileSystemRepository(private val dao: FileSystemDao, private val context: return } + val entity = dao.getFileByPathAndFolder(localPath, syncedFolder.id.toString()) + val fileSentForUpload = (entity != null && entity.fileSentForUpload == 1) + if (fileSentForUpload) { + Log_OC.d(TAG, "File was sent for upload, checking if it changed...") + } + val fileModified = (lastModified ?: file.lastModified()) - val shouldSkipFileBasedOnFolderSettings = syncedFolder.shouldSkipFile(file, fileModified, creationTime) - if (shouldSkipFileBasedOnFolderSettings) { + if (syncedFolder.shouldSkipFile(file, fileModified, creationTime, fileSentForUpload)) { return } - val entity = dao.getFileByPathAndFolder(localPath, syncedFolder.id.toString()) - if (entity != null && entity.fileSentForUpload == 1) { - Log_OC.w( - TAG, - "file already uploaded path: $localPath, " + - "syncedFolder: ${syncedFolder.localPath}, ${syncedFolder.id}" - ) - return + if (fileSentForUpload) { + Log_OC.d(TAG, "File was sent for upload before but has changed, will re-upload: $localPath") } val crc = getFileChecksum(file) @@ -182,7 +181,7 @@ class FileSystemRepository(private val dao: FileSystemDao, private val context: localPath = localPath, fileIsFolder = if (file.isDirectory) 1 else 0, fileFoundRecently = System.currentTimeMillis(), - fileSentForUpload = 0, + fileSentForUpload = 0, // Reset to 0 to queue for upload syncedFolderId = syncedFolder.id.toString(), crc32 = crc?.toString(), fileModified = fileModified diff --git a/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt index c7bdadfe6f54..7f8cac806f2f 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt @@ -22,7 +22,12 @@ private const val TAG = "SyncedFolderExtensions" * Determines whether a file should be skipped during auto-upload based on folder settings. */ @Suppress("ReturnCount") -fun SyncedFolder.shouldSkipFile(file: File, lastModified: Long, creationTime: Long?): Boolean { +fun SyncedFolder.shouldSkipFile( + file: File, + lastModified: Long, + creationTime: Long?, + fileSentForUpload: Boolean +): Boolean { Log_OC.d(TAG, "Checking file: ${file.name}, lastModified=$lastModified, lastScan=$lastScanTimestampMs") if (isExcludeHidden && file.isHidden) { @@ -38,15 +43,19 @@ fun SyncedFolder.shouldSkipFile(file: File, lastModified: Long, creationTime: Lo return true } } else { - Log_OC.w(TAG, "file sent for upload - cannot determine creation time: ${file.absolutePath}") + Log_OC.w(TAG, "file will be inserted to db - cannot determine creation time: ${file.absolutePath}") return false } } - // Skip files that haven't changed since last scan (already processed) - // BUT only if this is not the first scan - if (lastScanTimestampMs != -1L && lastModified < lastScanTimestampMs) { - Log_OC.d(TAG, "Skipping unchanged file (last modified < last scan): ${file.absolutePath}") + // Skip files that haven't changed since last scan ONLY if they were sent for upload + // AND only if this is not the first scan + if (fileSentForUpload && lastScanTimestampMs != -1L && lastModified < lastScanTimestampMs) { + Log_OC.d( + TAG, + "Skipping unchanged file that was already sent for upload (last modified < last scan): " + + "${file.absolutePath}" + ) return true } From 3a36caf82411865f33c7ff25fc4e34a904531aea Mon Sep 17 00:00:00 2001 From: alperozturk Date: Mon, 12 Jan 2026 12:59:21 +0100 Subject: [PATCH 110/125] update tests Signed-off-by: alperozturk Signed-off-by: Raphael Vieira --- .../android/utils/AutoUploadHelperTest.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt b/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt index 993ee41a2915..a7aff50a8cc3 100644 --- a/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt +++ b/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt @@ -132,10 +132,10 @@ class AutoUploadHelperTest { type = MediaFolderType.CUSTOM ) - val shouldSkipOldFile = folder.shouldSkipFile(oldFile, oldFileLastModified, null) + val shouldSkipOldFile = folder.shouldSkipFile(oldFile, oldFileLastModified, null, true) assertTrue(shouldSkipOldFile) - val shouldSkipNewFile = folder.shouldSkipFile(newFile, currentTime, null) + val shouldSkipNewFile = folder.shouldSkipFile(newFile, currentTime, null, false) assertTrue(!shouldSkipNewFile) } @@ -157,10 +157,10 @@ class AutoUploadHelperTest { lastScanTimestampMs = currentTime } - val shouldSkipOldFile = folder.shouldSkipFile(oldFile, oldFileLastModified, null) + val shouldSkipOldFile = folder.shouldSkipFile(oldFile, oldFileLastModified, null, true) assertTrue(shouldSkipOldFile) - val shouldSkipNewFile = folder.shouldSkipFile(newFile, currentTime, null) + val shouldSkipNewFile = folder.shouldSkipFile(newFile, currentTime, null, false) assertTrue(!shouldSkipNewFile) } @@ -305,10 +305,10 @@ class AutoUploadHelperTest { setEnabled(true, currentTime) } - val shouldSkipOldFile = folderSkipOld.shouldSkipFile(oldFile, oldFileLastModified, oldFileCreationTime) + val shouldSkipOldFile = folderSkipOld.shouldSkipFile(oldFile, oldFileLastModified, oldFileCreationTime, true) assertTrue(shouldSkipOldFile) - val shouldSkipNewFile = folderSkipOld.shouldSkipFile(newFile, newFileLastModified, newFileCreationTime) + val shouldSkipNewFile = folderSkipOld.shouldSkipFile(newFile, newFileLastModified, newFileCreationTime, false) assertTrue(!shouldSkipNewFile) val folderUploadAll = createTestFolder( @@ -320,11 +320,11 @@ class AutoUploadHelperTest { } val shouldSkipOldFileIfAlsoUploadExistingFile = - folderUploadAll.shouldSkipFile(oldFile, oldFileLastModified, oldFileCreationTime) + folderUploadAll.shouldSkipFile(oldFile, oldFileLastModified, oldFileCreationTime, true) assertTrue(!shouldSkipOldFileIfAlsoUploadExistingFile) val shouldSkipNewFileIfAlsoUploadExistingFile = - folderUploadAll.shouldSkipFile(newFile, newFileLastModified, newFileCreationTime) + folderUploadAll.shouldSkipFile(newFile, newFileLastModified, newFileCreationTime, false) assertTrue(!shouldSkipNewFileIfAlsoUploadExistingFile) } } From 9e76eddb5bc480ae23562509319a5c02efc19892 Mon Sep 17 00:00:00 2001 From: nextcloud-android-bot Date: Thu, 22 Jan 2026 08:43:13 +0000 Subject: [PATCH 111/125] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'.githu?= =?UTF-8?q?b/workflows/'=20with=20remote=20'config/workflows/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nextcloud-android-bot Signed-off-by: Raphael Vieira --- .github/workflows/analysis.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/qa.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 3a374918f603..6c82b6376978 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -57,7 +57,7 @@ jobs: repository: ${{ steps.get-vars.outputs.repo }} ref: ${{ steps.get-vars.outputs.branch }} - name: Set up JDK 21 - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: "temurin" java-version: 21 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a700d0e45e51..cca31d078f40 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: with: languages: ${{ matrix.language }} - name: Set up JDK 21 - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: "temurin" java-version: 21 diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index c493776642c4..770d09d9be25 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: set up JDK 21 - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 if: ${{ steps.check-secrets.outputs.ok == 'true' }} with: distribution: "temurin" From a0479fa525b1bd414ac57d41077212197bc30505 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 11:13:08 +0100 Subject: [PATCH 112/125] handle oc upload construct Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/database/entity/UploadEntity.kt | 9 +++++++-- .../jobs/autoUpload/AutoUploadWorker.kt | 20 ++++++++++++------- .../client/jobs/upload/FileUploadHelper.kt | 2 +- .../com/owncloud/android/db/OCUpload.java | 2 ++ 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt index ec9042cc5819..46b88d674d1c 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt @@ -18,6 +18,7 @@ import com.owncloud.android.db.ProviderMeta.ProviderTableMeta import com.owncloud.android.db.UploadResult import com.owncloud.android.files.services.NameCollisionPolicy import com.owncloud.android.lib.resources.status.OCCapability +import java.lang.IllegalArgumentException @Entity(tableName = ProviderTableMeta.UPLOADS_TABLE_NAME) data class UploadEntity( @@ -56,13 +57,17 @@ data class UploadEntity( val folderUnlockToken: String? ) -fun UploadEntity.toOCUpload(capability: OCCapability? = null): OCUpload { +fun UploadEntity.toOCUpload(capability: OCCapability? = null): OCUpload? { val localPath = localPath var remotePath = remotePath if (capability != null && remotePath != null) { remotePath = AutoRename.rename(remotePath, capability) } - val upload = OCUpload(localPath, remotePath, accountName) + val upload = try { + OCUpload(localPath, remotePath, accountName) + } catch (_: IllegalArgumentException) { + null + } ?: return null fileSize?.let { upload.fileSize = it } id?.let { upload.uploadId = it.toLong() } diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index 77d9efc41695..27f6c96e2943 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -383,6 +383,7 @@ class AutoUploadWorker( uploadsStorageManager.removeUpload(upload) } + @Suppress("ReturnCount") private fun createEntityAndUpload( user: User, localPath: String, @@ -404,13 +405,18 @@ class AutoUploadWorker( return null } - val upload = ( - uploadEntity?.toOCUpload(null) ?: OCUpload( - localPath, - remotePath, - user.accountName - ) - ).apply { + var upload = try { + (uploadEntity?.toOCUpload(null) ?: OCUpload(localPath, remotePath, user.accountName)) + } catch (_: IllegalArgumentException) { + null + } + + if (upload == null) { + Log_OC.e(TAG, "cannot construct oc upload") + return null + } + + upload = upload.apply { uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS nameCollisionPolicy = syncedFolder.nameCollisionPolicy isUseWifiOnly = needsWifi diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 5ec404cf3568..64ed8a0b9265 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -299,7 +299,7 @@ class FileUploadHelper { dao.getUploadsByAccountNameAndStatus(accountName, status.value, nameCollisionPolicy?.serialize()) } else { dao.getUploadsByStatus(status.value, nameCollisionPolicy?.serialize()) - }.map { it.toOCUpload(null) }.toTypedArray() + }.mapNotNull { it.toOCUpload(null) }.toTypedArray() onCompleted(result) } } diff --git a/app/src/main/java/com/owncloud/android/db/OCUpload.java b/app/src/main/java/com/owncloud/android/db/OCUpload.java index 167e9432e003..8679e3ebd726 100644 --- a/app/src/main/java/com/owncloud/android/db/OCUpload.java +++ b/app/src/main/java/com/owncloud/android/db/OCUpload.java @@ -130,9 +130,11 @@ public class OCUpload implements Parcelable { */ public OCUpload(String localPath, String remotePath, String accountName) { if (localPath == null || !localPath.startsWith(File.separator)) { + Log_OC.e(TAG, "oc upload, local path: " + localPath); throw new IllegalArgumentException("Local path must be an absolute path in the local file system"); } if (remotePath == null || !remotePath.startsWith(OCFile.PATH_SEPARATOR)) { + Log_OC.e(TAG, "oc upload, remote path: " + remotePath); throw new IllegalArgumentException("Remote path must be an absolute path in the local file system"); } if (accountName == null || accountName.length() < 1) { From 4313fd2f81c56643d89eda4f1b445685d624260f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alper=20=C3=96zt=C3=BCrk?= <67455295+alperozturk96@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:51:48 +0100 Subject: [PATCH 113/125] Apply suggestion from @ZetaTom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tom <70907959+ZetaTom@users.noreply.github.com> Signed-off-by: Alper Öztürk <67455295+alperozturk96@users.noreply.github.com> Signed-off-by: Raphael Vieira --- .../client/jobs/autoUpload/AutoUploadWorker.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index 27f6c96e2943..54b3fdec74b1 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -405,18 +405,14 @@ class AutoUploadWorker( return null } - var upload = try { - (uploadEntity?.toOCUpload(null) ?: OCUpload(localPath, remotePath, user.accountName)) + val upload = try { + uploadEntity?.toOCUpload(null) ?: OCUpload(localPath, remotePath, user.accountName) } catch (_: IllegalArgumentException) { - null - } - - if (upload == null) { Log_OC.e(TAG, "cannot construct oc upload") return null } - upload = upload.apply { + upload.apply { uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS nameCollisionPolicy = syncedFolder.nameCollisionPolicy isUseWifiOnly = needsWifi From 58462deb7fbadfdf807b8c1397e661d465043a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alper=20=C3=96zt=C3=BCrk?= <67455295+alperozturk96@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:53:21 +0100 Subject: [PATCH 114/125] Apply suggestion from @ZetaTom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tom <70907959+ZetaTom@users.noreply.github.com> Signed-off-by: Alper Öztürk <67455295+alperozturk96@users.noreply.github.com> Signed-off-by: Raphael Vieira --- .../java/com/nextcloud/client/database/entity/UploadEntity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt index 46b88d674d1c..55255f5fb01a 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/UploadEntity.kt @@ -66,8 +66,8 @@ fun UploadEntity.toOCUpload(capability: OCCapability? = null): OCUpload? { val upload = try { OCUpload(localPath, remotePath, accountName) } catch (_: IllegalArgumentException) { - null - } ?: return null + return null + } fileSize?.let { upload.fileSize = it } id?.let { upload.uploadId = it.toLong() } From 5c7f60a1fe75c7d93a3c95a8368134f2574259f2 Mon Sep 17 00:00:00 2001 From: Nextcloud bot Date: Fri, 23 Jan 2026 03:10:48 +0000 Subject: [PATCH 115/125] fix(l10n): Update translations from Transifex Signed-off-by: Nextcloud bot Signed-off-by: Raphael Vieira --- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-ast/strings.xml | 1 + app/src/main/res/values-bg-rBG/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-ga/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 5 +++++ app/src/main/res/values-it/strings.xml | 12 ++++++------ app/src/main/res/values-uk/strings.xml | 19 ++++++++++--------- app/src/main/res/values-zh-rTW/strings.xml | 1 + 10 files changed, 28 insertions(+), 15 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8ed15c818c37..484baced618a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -175,6 +175,7 @@ هل وجدت خطأ برمجي أو شيء ناقص؟ ساعدنا بالتجريب إرسل تقرير أخطاء على GitHub + مُساعِد نكست كلاود Nextcloud Assistant إعداد إلغاء التشفير المحلي هل توَدُّ حقاً حذف %1$s؟ diff --git a/app/src/main/res/values-ast/strings.xml b/app/src/main/res/values-ast/strings.xml index f1cdfd434851..248802f9b16d 100644 --- a/app/src/main/res/values-ast/strings.xml +++ b/app/src/main/res/values-ast/strings.xml @@ -95,6 +95,7 @@ Cambiar de cuenta Traducir + Asistente de Nextcloud Namái llocal Ficheru llocal Contautos diff --git a/app/src/main/res/values-bg-rBG/strings.xml b/app/src/main/res/values-bg-rBG/strings.xml index 2276e65904d4..07dcd54eecee 100644 --- a/app/src/main/res/values-bg-rBG/strings.xml +++ b/app/src/main/res/values-bg-rBG/strings.xml @@ -155,6 +155,7 @@ Открили сте грешка? Нещо странно? Помогни с тестването Докладвайте за проблем чрез Github + Асистент на \"Nextcloud\" Настройки Премахване на локалното криптиране Наистина ли желаете %1$s да бъде изтрит? diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 6ec01058f7ba..4c0b6166925f 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -172,6 +172,7 @@ Heu trobat cap error? Alguna cosa fora del corrent? Ajudar fent proves Informar d\'una incidència a GitHub + Assistent del Nextcloud Configura Suprimeix el xifratge local Esteu segur que voleu suprimir%1$s? diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 04fa10cfa4bc..70499133828c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -60,6 +60,7 @@ Die Aufgabenliste kann nicht abgerufen werden. Bitte überprüfen Sie Ihre Internetverbindung. Aufgabe löschen Die Aufgabenausgabe ist noch nicht fertig. + Text aus einer anderen App kopiert Assistent Eingabe Ausgabe diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 29e68f2e220d..461feea1fbbf 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -60,6 +60,7 @@ Ní féidir liosta tascanna a fháil, seiceáil do nasc idirlín le do thoil. Scrios Tasc Níl an t-aschur tasc réidh fós. + Téacs cóipeáilte ó aip eile Cúntóir Ionchur Aschur diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index f399a71ec6b1..9d37e3a2b3b8 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -44,8 +44,11 @@ Menampilkan satu gawit dari dasbor Cari dalam %s Tampak offline + Keluaran yang ditampilkan di sini dihasilkan oleh AI. Pastikan untuk selalu memeriksa ulang. Apakah Anda yakin ingin menghapus tugas ini? Hapus tugas + Coba kirim pesan untuk memulai percakapan. + Halo! Apa yang bisa saya bantu hari ini? Terjadi kesalahan saat membuat tugas Tugas berhasil dibuat Terjadi kesalahan saat menghapus tugas @@ -181,6 +184,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Menemukan kesalahan? Bantu dengan menguji. Laporkan di GitHub + Nextcloud Assistant Konfigurasi Hilangkan enkripsi lokal Apakah anda yakin ingin menghapus %1$s? @@ -206,6 +210,7 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini Tidak ada berkas ditemukan. Tidak dapat menemukan pencadangan terakhir kamu! Mendeteksi perubahan konten + Belum ada percakapan Tersalin Terjadi kesalahan ketika mencoba menyalin berkas atau folder ini Tidak memungkinkan untuk menyalin folder kedalam turunannya diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3d50a673a13e..a14a8f9f66f6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -53,11 +53,11 @@ Ciao! Come posso aiutarti oggi? Un errore è intercorso durante la creazione del task Task creato con successo - Un errore è intercorso durante la cancellazione del task - Task cancellato con successo + Un errore è intercorso durante l\'eliminazione dell\'attività + Attività eliminata correttamente L\'elenco delle attività è vuoto. Controllare la configurazione dell\'app di assistenza. Impossibile recuperare la lista dei Task, verifica la tua connessione a internet. - Cancella task + Elimina attività Il risultato del task non è ancora pronto. Assistente Input @@ -214,8 +214,8 @@ Non troviamo il tuo ultimo backup! Rilevamento delle modifiche ai contenuti Impossibile creare conversazione - Cancella conversazione - Impossibile cancellare conversazione + Elimina conversazione + Impossibile eliminare la conversazione Nessuna conversazione trovata Ancora nessuna conversazione Conversazioni @@ -280,7 +280,7 @@ Scaricamento in corso… %1$s scaricato Scaricati - Alcuni file sono stati cancellati dall\'utente durante il download + Lo scaricamento di alcuni file è stato annullato dall\'utente Errore durante lo scaricamento dei file Non ancora scaricato Errore inaspettato durante il download dei file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6b1de22684f1..43a9cfc66ced 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -60,6 +60,7 @@ Неможливо отримати список завдань. Перевірте з\'єднання з мережею Вилучити завдання Поки неможливо показати завдання + Текст скопійовано з іншого застосунку Помічник Введення Виведення @@ -142,9 +143,9 @@ Неможливо очистити сповіщення. Очистити статус після Текст скопійовано з %1$s - Відсутній текст для копіювання до буферу обміну + Відсутній текст для копіювання до буфера обміну Посилання скопійовано - Неочікувана помилка під час копіювання до буферу обміну + Неочікувана помилка під час копіювання до буфера обміну Назад Скасувати Скасувати синхронізацію @@ -581,7 +582,7 @@ Журнал подій Сервер у режимі обслуговування Очистити дані - Налаштування, база даних та дані сертифікатів серверу від %1$s буде вилучено без можливості відновлення.\n\nФайли, які було звантажено, буде збережено.\n\nЦей процес триватиме певний час. + Налаштування, база даних та дані сертифікатів сервера від %1$s буде вилучено без можливості відновлення.\n\nФайли, які було звантажено, буде збережено.\n\nЦей процес триватиме певний час. Керувати простором Неможливо транслювати мультимедійний файл Неможливо відкрити мультимедійний файл @@ -862,7 +863,7 @@ Ви повинні ввести пароль Помилка під час надання спільного доступу до файлу чи каталогу. Неможливо надати спільний доступ. Будь ласка, перевірте, чи файл існує. - для надання доступу до файла + для надання доступу до файлу Введіть пароль (необов\'язково) Введіть пароль Поділитися посиланням (%1$s) @@ -926,9 +927,9 @@ Неможливо зберегти сертифікат Не вдалося показати сертифікат. Ви все одно бажаєте довіряти цьому сертифікату? - - Сертифікат серверу втратив чинність - - Недовірений сертифікат серверу - - Сертифікат серверу занадто новий + - Сертифікат сервера втратив чинність + - Недовірений сертифікат сервера + - Дійсні дати сертифіката сервера в майбутньому - URL не відповідає імені хоста у сертифікаті Повідомлення про статус Камера @@ -1030,7 +1031,7 @@ Вилучити каталог із внутрішньої двосторонньої синхронізації Помилка під час закриття спільного доступу до файлу чи каталогу. Неможливо закрити спільний доступ. Будь ласка, перевірте, чи файл існує. - для закриття доступу до файла + для закриття доступу до файлу Не вдалося припинити спільний доступ Доступ через ненадійний домен. Будь ласка, перегляньте документацію для отримання додаткової інформації. Помилка під час оновлення спільного ресурсу. @@ -1125,7 +1126,7 @@ Помилка каталогу Файл на пристрої не знайдено Помилка доступу - Не довірений сертифікат серверу + Недовірений сертифікат сервера Отримую версію сервера... Застосунок зупинено Пропущено diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 41e2b24f983a..68f16e1a8112 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -60,6 +60,7 @@ 無法擷取工作項目清單,請檢查您的網際網路連線。 刪除工作項目 任務輸出尚未就緒。 + 已從其他應用程式複製文字 助理 輸入 輸出 From 6c51f9184fa99a59b2d7766b1e3b1548b3a77d43 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 09:23:46 +0100 Subject: [PATCH 116/125] fix(auto-upload): sync conflict handling Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/jobs/autoUpload/AutoUploadWorker.kt | 7 +++++++ .../jobs/utils/UploadErrorNotificationManager.kt | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index 54b3fdec74b1..db464ccfa11d 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -37,6 +37,7 @@ import com.owncloud.android.db.OCUpload import com.owncloud.android.db.UploadResult import com.owncloud.android.lib.common.OwnCloudAccount import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.ui.activity.SettingsActivity @@ -346,6 +347,12 @@ class AutoUploadWorker( TAG, "❌ upload failed $localPath (${upload.accountName}): ${result.logMessage}" ) + + // Mark CONFLICT files as handled to prevent retries + if (result.code == RemoteOperationResult.ResultCode.SYNC_CONFLICT) { + repository.markFileAsHandled(localPath, syncedFolder) + Log_OC.w(TAG, "Marked CONFLICT file as handled: $localPath") + } } } catch (e: Exception) { uploadsStorageManager.updateStatus( diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index a9aa21d61f73..236919c81b3c 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -80,7 +80,10 @@ object UploadErrorNotificationManager { result: RemoteOperationResult, notifyOnSameFileExists: suspend () -> Unit ): Notification? { - if (!shouldShowConflictDialog(isSameFileOnRemote, operation, result, notifyOnSameFileExists)) return null + if (!shouldShowConflictDialog(isSameFileOnRemote, operation, result, notifyOnSameFileExists)) { + Log_OC.d(TAG, "no need to show conflict resolve notification") + return null + } val textId = result.code.toFailedResultTitleId() val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(result, operation, context.resources) @@ -176,9 +179,14 @@ object UploadErrorNotificationManager { return false } - if (result.code == ResultCode.SYNC_CONFLICT && isSameFileOnRemote) { - Log_OC.w(TAG, "same file exists on remote") - notifyOnSameFileExists() + if (result.code == ResultCode.SYNC_CONFLICT) { + if (isSameFileOnRemote) { + Log_OC.w(TAG, "same file exists on remote") + notifyOnSameFileExists() + } else { + Log_OC.w(TAG, "SYNC_CONFLICT but file not same on remote - no notification needed") + } + return false } From 0856f55b81dd0aca2bc877b593da21189d911040 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 09:33:46 +0100 Subject: [PATCH 117/125] fix(auto-upload): sync conflict handling Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/jobs/utils/UploadErrorNotificationManager.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index 236919c81b3c..c3a5423f239b 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -183,8 +183,6 @@ object UploadErrorNotificationManager { if (isSameFileOnRemote) { Log_OC.w(TAG, "same file exists on remote") notifyOnSameFileExists() - } else { - Log_OC.w(TAG, "SYNC_CONFLICT but file not same on remote - no notification needed") } return false From 2d60b7168270eb4f0b21e1c031e48f73d0073b91 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 10:07:33 +0100 Subject: [PATCH 118/125] fix(auto-upload): simplify error notification logic Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/jobs/upload/FileUploadWorker.kt | 2 +- .../utils/UploadErrorNotificationManager.kt | 129 +++++++++--------- 2 files changed, 62 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index ab6d56c50dad..a67e81ff68ba 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -377,7 +377,7 @@ class FileUploadWorker( notificationManager, operation, result, - showSameFileAlreadyExistsNotification = { + onSameFileConflict = { withContext(Dispatchers.Main) { val showSameFileAlreadyExistsNotification = inputData.getBoolean(SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false) diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index c3a5423f239b..d9e5abb77963 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -31,35 +31,67 @@ import java.io.File object UploadErrorNotificationManager { private const val TAG = "UploadErrorNotificationManager" + /** + * Processes the result of an upload operation and manages error notifications. + * * It filters out successful or silent results and handles [ResultCode.SYNC_CONFLICT] + * by checking if the remote file is identical. If it's a "real" conflict or error, + * it displays a notification with relevant actions (e.g., Resolve Conflict, Pause, Cancel). + * + * @param onSameFileConflict Triggered only if a 409 Conflict occurs but files are identical. + */ + @Suppress("ReturnCount") suspend fun handleResult( context: Context, notificationManager: WorkerNotificationManager, operation: UploadFileOperation, result: RemoteOperationResult, - showSameFileAlreadyExistsNotification: suspend () -> Unit = {} + onSameFileConflict: suspend () -> Unit = {} ) { Log_OC.d(TAG, "handle upload result with result code: " + result.code) - val notification = withContext(Dispatchers.IO) { - val isSameFileOnRemote = FileUploadHelper.instance().isSameFileOnRemote( - operation.user, - File(operation.storagePath), - operation.remotePath, - context - ) + if (result.isSuccess || result.isCancelled || operation.isMissingPermissionThrown) { + Log_OC.d(TAG, "operation is successful, cancelled or lack of storage permission, notification skipped") + return + } - getNotification( - isSameFileOnRemote, - context, - notificationManager.notificationBuilder, - operation, - result, - notifyOnSameFileExists = { - showSameFileAlreadyExistsNotification() - operation.handleLocalBehaviour() - } - ) - } ?: return + val silentCodes = setOf( + ResultCode.DELAYED_FOR_WIFI, + ResultCode.DELAYED_FOR_CHARGING, + ResultCode.DELAYED_IN_POWER_SAVE_MODE, + ResultCode.LOCAL_FILE_NOT_FOUND, + ResultCode.LOCK_FAILED + ) + + if (result.code in silentCodes) { + Log_OC.d(TAG, "silent error code, notification skipped") + return + } + + // Do not show an error notification when uploading the same file again (manual uploads only). + if (result.code == ResultCode.SYNC_CONFLICT) { + val isSameFile = withContext(Dispatchers.IO) { + FileUploadHelper.instance().isSameFileOnRemote( + operation.user, + File(operation.storagePath), + operation.remotePath, + context + ) + } + + if (isSameFile) { + Log_OC.w(TAG, "exact same file already exists on remote, error notification skipped") + onSameFileConflict() + return + } + } + + // now we can show error notification + val notification = getNotification( + context, + notificationManager.notificationBuilder, + operation, + result + ) Log_OC.d(TAG, "🔔" + "notification created") @@ -72,19 +104,12 @@ object UploadErrorNotificationManager { } } - private suspend fun getNotification( - isSameFileOnRemote: Boolean, + private fun getNotification( context: Context, builder: NotificationCompat.Builder, operation: UploadFileOperation, - result: RemoteOperationResult, - notifyOnSameFileExists: suspend () -> Unit - ): Notification? { - if (!shouldShowConflictDialog(isSameFileOnRemote, operation, result, notifyOnSameFileExists)) { - Log_OC.d(TAG, "no need to show conflict resolve notification") - return null - } - + result: RemoteOperationResult + ): Notification { val textId = result.code.toFailedResultTitleId() val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(result, operation, context.resources) @@ -97,7 +122,11 @@ object UploadErrorNotificationManager { setProgress(0, 0, false) clearActions() - result.code.takeIf { it == ResultCode.SYNC_CONFLICT }?.let { + // actions for all error types + addAction(UploadBroadcastAction.PauseAndCancel(operation).pauseAction(context)) + addAction(UploadBroadcastAction.PauseAndCancel(operation).cancelAction(context)) + + if (result.code == ResultCode.SYNC_CONFLICT) { addAction( R.drawable.ic_cloud_upload, context.getString(R.string.upload_list_resolve_conflict), @@ -105,11 +134,7 @@ object UploadErrorNotificationManager { ) } - addAction(UploadBroadcastAction.PauseAndCancel(operation).pauseAction(context)) - - addAction(UploadBroadcastAction.PauseAndCancel(operation).cancelAction(context)) - - result.code.takeIf { it == ResultCode.UNAUTHORIZED }?.let { + if (result.code == ResultCode.UNAUTHORIZED) { setContentIntent(credentialPendingIntent(context, operation)) } }.build() @@ -162,36 +187,4 @@ object UploadErrorNotificationManager { PendingIntent.FLAG_IMMUTABLE ) } - - @Suppress("ReturnCount", "ComplexCondition") - private suspend fun shouldShowConflictDialog( - isSameFileOnRemote: Boolean, - operation: UploadFileOperation, - result: RemoteOperationResult, - notifyOnSameFileExists: suspend () -> Unit - ): Boolean { - if (result.isSuccess || - result.isCancelled || - result.code == ResultCode.USER_CANCELLED || - operation.isMissingPermissionThrown - ) { - Log_OC.w(TAG, "operation is successful, cancelled or lack of storage permission") - return false - } - - if (result.code == ResultCode.SYNC_CONFLICT) { - if (isSameFileOnRemote) { - Log_OC.w(TAG, "same file exists on remote") - notifyOnSameFileExists() - } - - return false - } - - val delayedCodes = - setOf(ResultCode.DELAYED_FOR_WIFI, ResultCode.DELAYED_FOR_CHARGING, ResultCode.DELAYED_IN_POWER_SAVE_MODE) - val invalidCodes = setOf(ResultCode.LOCAL_FILE_NOT_FOUND, ResultCode.LOCK_FAILED) - - return result.code !in delayedCodes && result.code !in invalidCodes - } } From 413b6ef08ab5d6922a54bd8a7762b0a60a782f89 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 10:10:02 +0100 Subject: [PATCH 119/125] fix(auto-upload): simplify error notification logic Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/jobs/utils/UploadErrorNotificationManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index d9e5abb77963..5777704af50e 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -37,7 +37,7 @@ object UploadErrorNotificationManager { * by checking if the remote file is identical. If it's a "real" conflict or error, * it displays a notification with relevant actions (e.g., Resolve Conflict, Pause, Cancel). * - * @param onSameFileConflict Triggered only if a 409 Conflict occurs but files are identical. + * @param onSameFileConflict Triggered only if result code is SYNC_CONFLICT and files are identical. */ @Suppress("ReturnCount") suspend fun handleResult( From c8ac9d7ae1a64a49e692def9a8890d9e454e3767 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 10:11:40 +0100 Subject: [PATCH 120/125] remove public handleLocalBehaviour Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../operations/UploadFileOperation.java | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index f13c73704962..f29faf6d308a 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -1262,27 +1262,6 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, return null; } - public void handleLocalBehaviour() { - if (user == null || mFile == null || mContext == null) { - Log_OC.d(TAG, "handleLocalBehaviour: user, file, or context is null."); - return; - } - - final var client = getClient(); - if (client == null) { - Log_OC.d(TAG, "handleLocalBehaviour: client is null"); - return; - } - - String expectedPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mFile); - File expectedFile = new File(expectedPath); - File originalFile = new File(mOriginalStoragePath); - String temporalPath = FileStorageUtils.getInternalTemporalPath(user.getAccountName(), mContext) + mFile.getRemotePath(); - File temporalFile = new File(temporalPath); - - handleLocalBehaviour(temporalFile, expectedFile, originalFile, client); - } - private void deleteNonExistingFile(File file) { if (file.exists()) { return; From 5f74f9e920022126d29184cc72487114b953bb08 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 10:14:40 +0100 Subject: [PATCH 121/125] remove public handleLocalBehaviour Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/jobs/utils/UploadErrorNotificationManager.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index 5777704af50e..0371a8d53b45 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -67,7 +67,7 @@ object UploadErrorNotificationManager { return } - // Do not show an error notification when uploading the same file again (manual uploads only). + // do not show an error notification when uploading the same file again if (result.code == ResultCode.SYNC_CONFLICT) { val isSameFile = withContext(Dispatchers.IO) { FileUploadHelper.instance().isSameFileOnRemote( @@ -80,6 +80,8 @@ object UploadErrorNotificationManager { if (isSameFile) { Log_OC.w(TAG, "exact same file already exists on remote, error notification skipped") + + // only show notification for manual uploads onSameFileConflict() return } From c5971ad928009f063601fcdae29a241899d7c197 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 10:15:34 +0100 Subject: [PATCH 122/125] remove public handleLocalBehaviour Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/jobs/utils/UploadErrorNotificationManager.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index 0371a8d53b45..41edefe7d6fb 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -98,6 +98,8 @@ object UploadErrorNotificationManager { Log_OC.d(TAG, "🔔" + "notification created") withContext(Dispatchers.Main) { + + // if error code is file specific show new notification for each file if (result.code.isFileSpecificError()) { notificationManager.showNotification(operation.ocUploadId.toInt(), notification) } else { From ede682ed51863dfa0117c792ec2d6a0b10e07c4f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 22 Jan 2026 15:55:27 +0100 Subject: [PATCH 123/125] fix codacy Signed-off-by: alperozturk96 Signed-off-by: Raphael Vieira --- .../client/jobs/utils/UploadErrorNotificationManager.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index 41edefe7d6fb..5240f112a7f8 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -98,7 +98,6 @@ object UploadErrorNotificationManager { Log_OC.d(TAG, "🔔" + "notification created") withContext(Dispatchers.Main) { - // if error code is file specific show new notification for each file if (result.code.isFileSpecificError()) { notificationManager.showNotification(operation.ocUploadId.toInt(), notification) From 77c92653f9fe940bc39ee6f08ec484fb3a1c95e8 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Sat, 24 Jan 2026 01:19:38 +0000 Subject: [PATCH 124/125] cleaned up imports Signed-off-by: Raphael Vieira --- .../com/nextcloud/client/jobs/BackgroundJobManagerTest.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt index 1e3e50d86623..3a9c417a32c7 100644 --- a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt @@ -11,11 +11,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.test.annotation.UiThreadTest -import androidx.test.runner.screenshot.Screenshot import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest import androidx.work.WorkContinuation @@ -27,12 +25,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.utils.extensions.toByteArray import com.owncloud.android.lib.common.utils.Log_OC -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot import org.apache.commons.io.FileUtils -import org.bouncycastle.util.test.SimpleTest.runTest -import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull From 50b2d932ac5307b7d0fe2eafd7862b8a15bf2bf4 Mon Sep 17 00:00:00 2001 From: Raphael Vieira Date: Sun, 25 Jan 2026 18:14:33 +0000 Subject: [PATCH 125/125] removed magic number Signed-off-by: Raphael Vieira --- .../com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 66be2c4a5c17..66040ea31886 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -117,6 +117,11 @@ internal class BackgroundJobManagerImpl( private const val KEEP_LOG_MILLIS = 1000 * 60 * 60 * 24 * 3L + /** + * The maximum number of concurrent parallel uploads + */ + const val MAX_CONCURRENT_UPLOADS = 5 + fun formatNameTag(name: String, user: User? = null): String = if (user == null) { "$TAG_PREFIX_NAME:$name" } else { @@ -657,7 +662,7 @@ internal class BackgroundJobManagerImpl( */ override fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) { defaultDispatcherScope.launch { - val chunkSize = (uploadIds.size / 5).coerceAtLeast(1) + val chunkSize = (uploadIds.size / MAX_CONCURRENT_UPLOADS).coerceAtLeast(1) val batches = uploadIds.toList().chunked(chunkSize) val executionId = System.currentTimeMillis() val tag = "${startFileUploadJobTag(user.accountName)}_$executionId"