From 76d88d24336be5cb8f16a0c676f570946edc7acf Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 11:00:43 +0200 Subject: [PATCH 01/12] fix crash Signed-off-by: alperozturk96 --- .../client/jobs/upload/FileUploadHelper.kt | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 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 816c81898b24..a5c1e1457847 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 @@ -39,6 +39,7 @@ import com.owncloud.android.db.UploadResult import com.owncloud.android.files.services.NameCollisionPolicy 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.network.OnDatatransferProgressListener import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC @@ -51,8 +52,10 @@ import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -74,7 +77,13 @@ class FileUploadHelper { @Inject lateinit var fileStorageManager: FileDataStorageManager - private val ioScope = CoroutineScope(Dispatchers.IO) + private val ioScope = CoroutineScope( + SupervisorJob() + + Dispatchers.IO + + CoroutineExceptionHandler { _, throwable -> + Log_OC.e(TAG, "Uncaught exception in FileUploadHelper coroutine", throwable) + } + ) init { MainApp.getAppComponent().inject(this) @@ -131,8 +140,8 @@ class FileUploadHelper { var isUploadStarted = false val capability = fileStorageManager.getCapability(accountManager.user) - try { - ioScope.launch { + ioScope.launch { + try { val uploads = getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED, capability) if (uploads.isNotEmpty()) { isUploadStarted = true @@ -145,9 +154,11 @@ class FileUploadHelper { powerManagementService, uploads ) + } finally { + // Release only after retry processing has completely finished so the semaphore guards + // coroutine execution, not just its launch. This keeps a single retry running at a time. + retryFailedUploadsSemaphore.release() } - } finally { - retryFailedUploadsSemaphore.release() } return isUploadStarted @@ -190,8 +201,12 @@ class FileUploadHelper { val context = MainApp.getAppContext() var ownCloudClient: OwnCloudClient? = null if (!currentAccount.isAnonymous(context)) { - ownCloudClient = + ownCloudClient = try { OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, MainApp.getAppContext()) + } catch (e: AccountUtils.AccountNotFoundException) { + Log_OC.e(TAG, "Cannot create client, account not found; skipping conflict handling", e) + null + } } val uploadActionHandler = UploadListAdapterActionHandler() From 99e5060005a388082f61082177c998f11c55ec30 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 11:09:31 +0200 Subject: [PATCH 02/12] introduce application scope to DispatcherModule.kt Signed-off-by: alperozturk96 --- .../nextcloud/client/di/DispatcherModule.kt | 28 +++++++++++++++++++ .../client/jobs/upload/FileUploadHelper.kt | 19 +++++-------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt b/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt index ab15cdf6e541..18fe0aadda65 100644 --- a/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt @@ -1,17 +1,23 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.nextcloud.client.di +import com.owncloud.android.lib.common.utils.Log_OC import dagger.Module import dagger.Provides import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import javax.inject.Qualifier +import javax.inject.Singleton @Retention(AnnotationRetention.BINARY) @Qualifier @@ -25,8 +31,15 @@ annotation class IoDispatcher @Qualifier annotation class MainDispatcher +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationScope + @Module object DispatcherModule { + + private const val APPLICATION_SCOPE_TAG = "ApplicationScope" + @DefaultDispatcher @Provides fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default @@ -38,4 +51,19 @@ object DispatcherModule { @MainDispatcher @Provides fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + /** + * A process-lifetime [CoroutineScope] for singletons that outlive any single Android component. + */ + @ApplicationScope + @Provides + @Singleton + fun provideApplicationScope(@IoDispatcher dispatcher: CoroutineDispatcher): CoroutineScope = + CoroutineScope( + SupervisorJob() + + dispatcher + + CoroutineExceptionHandler { _, throwable -> + Log_OC.e(APPLICATION_SCOPE_TAG, "Uncaught exception in application coroutine scope", throwable) + } + ) } 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 a5c1e1457847..6978d7102387 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 @@ -19,6 +19,7 @@ import com.nextcloud.client.database.entity.toOCUpload import com.nextcloud.client.database.entity.toUploadEntity import com.nextcloud.client.device.BatteryStatus import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.di.ApplicationScope import com.nextcloud.client.jobs.BackgroundJobManager import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService @@ -52,10 +53,8 @@ import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler import com.owncloud.android.utils.DisplayUtils -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -77,13 +76,9 @@ class FileUploadHelper { @Inject lateinit var fileStorageManager: FileDataStorageManager - private val ioScope = CoroutineScope( - SupervisorJob() + - Dispatchers.IO + - CoroutineExceptionHandler { _, throwable -> - Log_OC.e(TAG, "Uncaught exception in FileUploadHelper coroutine", throwable) - } - ) + @Inject + @ApplicationScope + lateinit var appScope: CoroutineScope init { MainApp.getAppComponent().inject(this) @@ -140,7 +135,7 @@ class FileUploadHelper { var isUploadStarted = false val capability = fileStorageManager.getCapability(accountManager.user) - ioScope.launch { + appScope.launch { try { val uploads = getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED, capability) if (uploads.isNotEmpty()) { @@ -360,7 +355,7 @@ class FileUploadHelper { status: UploadStatus, onCompleted: () -> Unit = {} ) { - ioScope.launch { + appScope.launch { uploadsStorageManager.uploadDao.updateStatus(remotePath, accountName, status.value) onCompleted() } @@ -546,7 +541,7 @@ class FileUploadHelper { * @param user Needed for creating client */ fun removeDuplicatedFile(duplicatedFile: OCFile, client: OwnCloudClient, user: User, onCompleted: () -> Unit) { - ioScope.launch { + appScope.launch { val removeFileOperation = RemoveFileOperation( duplicatedFile, false, From 708a4b98d5bb48f9121f71354af393e8dec0e5b9 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 11:14:03 +0200 Subject: [PATCH 03/12] use lazy for shared instance Signed-off-by: alperozturk96 --- .../com/nextcloud/client/jobs/upload/FileUploadHelper.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 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 6978d7102387..50492794ce3e 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 @@ -91,13 +91,11 @@ class FileUploadHelper { val mBoundListeners = HashMap() - private var instance: FileUploadHelper? = null - private val retryFailedUploadsSemaphore = Semaphore(1) - fun instance(): FileUploadHelper = instance ?: synchronized(this) { - instance ?: FileUploadHelper().also { instance = it } - } + private val sharedInstance: FileUploadHelper by lazy { FileUploadHelper() } + + fun instance(): FileUploadHelper = sharedInstance fun buildRemoteName(accountName: String, remotePath: String): String = accountName + remotePath } From 15ea6fd385f4d67171e536f535f80fc45e639b0d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 11:23:58 +0200 Subject: [PATCH 04/12] use atomic boolean instead of semaphore and remove broken flag since events will be firing Signed-off-by: alperozturk96 --- .../client/jobs/upload/FileUploadHelper.kt | 20 +++++++------------ .../android/ui/activity/UploadListActivity.kt | 6 +----- 2 files changed, 8 insertions(+), 18 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 50492794ce3e..c065b02f3ca6 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 @@ -58,7 +58,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import java.util.concurrent.Semaphore +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @Suppress("TooManyFunctions") @@ -91,7 +91,7 @@ class FileUploadHelper { val mBoundListeners = HashMap() - private val retryFailedUploadsSemaphore = Semaphore(1) + private val retryInProgress = AtomicBoolean(false) private val sharedInstance: FileUploadHelper by lazy { FileUploadHelper() } @@ -124,21 +124,17 @@ class FileUploadHelper { connectivityService: ConnectivityService, accountManager: UserAccountManager, powerManagementService: PowerManagementService - ): Boolean { - if (!retryFailedUploadsSemaphore.tryAcquire()) { + ) { + if (!retryInProgress.compareAndSet(false, true)) { Log_OC.d(TAG, "skipping retryFailedUploads, already running") - return true + return } - var isUploadStarted = false val capability = fileStorageManager.getCapability(accountManager.user) appScope.launch { try { val uploads = getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED, capability) - if (uploads.isNotEmpty()) { - isUploadStarted = true - } retryUploads( uploadsStorageManager, @@ -148,13 +144,11 @@ class FileUploadHelper { uploads ) } finally { - // Release only after retry processing has completely finished so the semaphore guards + // Reset only after retry processing has completely finished so the guard covers // coroutine execution, not just its launch. This keeps a single retry running at a time. - retryFailedUploadsSemaphore.release() + retryInProgress.set(false) } } - - return isUploadStarted } suspend fun retryCancelledUploads( diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt index fc742f2ac97f..b6ef36356a6b 100755 --- a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt @@ -168,16 +168,12 @@ class UploadListActivity : } private fun refresh() { - val isUploadStarted = FileUploadHelper.instance().retryFailedUploads( + FileUploadHelper.instance().retryFailedUploads( uploadsStorageManager, connectivityService, accountManager, powerManagementService ) - - if (!isUploadStarted) { - uploadListAdapter.loadUploadItemsFromDb { swipeListRefreshLayout?.isRefreshing = false } - } } override fun onStart() { From 3cefeb792b8a00e8dcecf49d19f2c9a7567c619e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 08:27:33 +0200 Subject: [PATCH 05/12] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/client/di/DispatcherModule.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt b/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt index 18fe0aadda65..8812ffbe428f 100644 --- a/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt @@ -58,12 +58,11 @@ object DispatcherModule { @ApplicationScope @Provides @Singleton - fun provideApplicationScope(@IoDispatcher dispatcher: CoroutineDispatcher): CoroutineScope = - CoroutineScope( - SupervisorJob() + - dispatcher + - CoroutineExceptionHandler { _, throwable -> - Log_OC.e(APPLICATION_SCOPE_TAG, "Uncaught exception in application coroutine scope", throwable) - } - ) + fun provideApplicationScope(@IoDispatcher dispatcher: CoroutineDispatcher): CoroutineScope = CoroutineScope( + SupervisorJob() + + dispatcher + + CoroutineExceptionHandler { _, throwable -> + Log_OC.e(APPLICATION_SCOPE_TAG, "Uncaught exception in application coroutine scope", throwable) + } + ) } From 30e6ed0b43e80b4be49aaedac0f417b70662e48d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 08:28:20 +0200 Subject: [PATCH 06/12] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c065b02f3ca6..a010daf183af 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 @@ -216,7 +216,7 @@ class FileUploadHelper { if (uploadResult != UploadResult.UPLOADED) { if (upload.lastResult != uploadResult) { - // Setting Upload status else cancelled uploads will behave wrong, when retrying + // Setting Upload status else canceled uploads will behave wrong, when retrying // Needs to happen first since lastResult wil be overwritten by setter upload.uploadStatus = UploadStatus.UPLOAD_FAILED From 67351c5af2ab5a6371a31d3ca359bd48a3a46254 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 08:35:12 +0200 Subject: [PATCH 07/12] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a010daf183af..a384bc924bce 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 @@ -84,6 +84,8 @@ class FileUploadHelper { MainApp.getAppComponent().inject(this) } + private val uploadActionHandler = UploadListAdapterActionHandler() + companion object { private val TAG = FileUploadWorker::class.java.simpleName @@ -195,7 +197,6 @@ class FileUploadHelper { null } } - val uploadActionHandler = UploadListAdapterActionHandler() for (upload in uploads) { if (upload.isLastResultConflictError()) { From 6baae303e1f120d2e415139a277453c14bac4058 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 08:50:29 +0200 Subject: [PATCH 08/12] implement client creation to user account manager Signed-off-by: alperozturk96 --- .../client/account/UserAccountManager.java | 4 ++ .../account/UserAccountManagerImpl.java | 8 +++- .../client/jobs/upload/FileUploadHelper.kt | 21 ++-------- .../UserAccountManagerExtensions.kt | 38 +++++++++++++++++++ 4 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt diff --git a/app/src/main/java/com/nextcloud/client/account/UserAccountManager.java b/app/src/main/java/com/nextcloud/client/account/UserAccountManager.java index 3317e180192d..89fb27b2e1f5 100644 --- a/app/src/main/java/com/nextcloud/client/account/UserAccountManager.java +++ b/app/src/main/java/com/nextcloud/client/account/UserAccountManager.java @@ -9,6 +9,7 @@ import android.accounts.Account; import android.accounts.AccountManager; import android.app.Activity; +import android.content.Context; import android.content.Intent; import com.owncloud.android.MainApp; @@ -30,6 +31,9 @@ public interface UserAccountManager extends CurrentAccountProvider { String ACCOUNT_USES_STANDARD_PASSWORD = "ACCOUNT_USES_STANDARD_PASSWORD"; String PENDING_FOR_REMOVAL = "PENDING_FOR_REMOVAL"; + @Nullable + Context getContext(); + @Nullable OwnCloudAccount getCurrentOwnCloudAccount(); diff --git a/app/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java b/app/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java index 3aec76281df3..8b941d9f70e6 100644 --- a/app/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java +++ b/app/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java @@ -55,7 +55,7 @@ public class UserAccountManagerImpl implements UserAccountManager { private static final String TAG = UserAccountManagerImpl.class.getSimpleName(); private static final String PREF_SELECT_OC_ACCOUNT = "select_oc_account"; - private Context context; + private final Context context; private final AccountManager accountManager; public static UserAccountManagerImpl fromContext(Context context) { @@ -305,6 +305,12 @@ public User getAnonymousUser() { return AnonymousUser.fromContext(context); } + @Nullable + @Override + public Context getContext() { + return context; + } + @Override @Nullable public OwnCloudAccount getCurrentOwnCloudAccount() { 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 a384bc924bce..714a7d0eb382 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 @@ -25,8 +25,8 @@ import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.notifications.AppWideNotificationManager import com.nextcloud.utils.extensions.checkWCFRestrictions +import com.nextcloud.utils.extensions.createOwncloudClient import com.nextcloud.utils.extensions.getUploadIds -import com.nextcloud.utils.extensions.isAnonymous import com.nextcloud.utils.extensions.isLastResultConflictError import com.nextcloud.utils.extensions.isSame import com.owncloud.android.MainApp @@ -39,8 +39,6 @@ import com.owncloud.android.db.OCUpload import com.owncloud.android.db.UploadResult import com.owncloud.android.files.services.NameCollisionPolicy 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.network.OnDatatransferProgressListener import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC @@ -185,24 +183,13 @@ class FileUploadHelper { val batteryStatus = powerManagementService.battery val uploadsToRetry = mutableListOf() - - val currentAccount = accountManager.currentAccount - val context = MainApp.getAppContext() - var ownCloudClient: OwnCloudClient? = null - if (!currentAccount.isAnonymous(context)) { - ownCloudClient = try { - OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, MainApp.getAppContext()) - } catch (e: AccountUtils.AccountNotFoundException) { - Log_OC.e(TAG, "Cannot create client, account not found; skipping conflict handling", e) - null - } - } + val client = accountManager.createOwncloudClient() for (upload in uploads) { if (upload.isLastResultConflictError()) { - ownCloudClient?.let { + client?.let { conflictHandlingResult = - uploadActionHandler.handleConflict(upload, ownCloudClient, uploadsStorageManager) + uploadActionHandler.handleConflict(upload, client = it, uploadsStorageManager) } continue } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt new file mode 100644 index 000000000000..200f3da6817d --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.MainApp +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.utils.Log_OC + +private const val TAG = "UserAccountManagerExtensions" + +fun UserAccountManager.createOwncloudClient(): OwnCloudClient? { + val context = context ?: MainApp.getAppContext() + if (context == null) { + Log_OC.e(TAG, "app context is null, cannot create client") + return null + } + + if (currentAccount.isAnonymous(context)) { + Log_OC.e(TAG, "current account is anonymous, cannot create client") + return null + } + + return try { + val result = OwnCloudClientFactory.createOwnCloudClient(currentAccount, context) + Log_OC.i(TAG, "client created") + result + } catch (e: Exception) { + Log_OC.e(TAG, "cannot create client: ", e) + null + } +} From 279d1695eaf20345ab550c8f8d1ce9def10a90aa Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 08:52:10 +0200 Subject: [PATCH 09/12] do not recreate same current account Signed-off-by: alperozturk96 --- .../utils/extensions/UserAccountManagerExtensions.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt index 200f3da6817d..004efd58dbbf 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt @@ -22,13 +22,14 @@ fun UserAccountManager.createOwncloudClient(): OwnCloudClient? { return null } - if (currentAccount.isAnonymous(context)) { + val account = currentAccount + if (account.isAnonymous(context)) { Log_OC.e(TAG, "current account is anonymous, cannot create client") return null } return try { - val result = OwnCloudClientFactory.createOwnCloudClient(currentAccount, context) + val result = OwnCloudClientFactory.createOwnCloudClient(account, context) Log_OC.i(TAG, "client created") result } catch (e: Exception) { From c3cdd38f0d036182de043d7386cdb741f788b052 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 08:58:40 +0200 Subject: [PATCH 10/12] wip Signed-off-by: alperozturk96 --- .../nextcloud/utils/extensions/UserAccountManagerExtensions.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt index 004efd58dbbf..ffe015414f23 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt @@ -15,6 +15,7 @@ import com.owncloud.android.lib.common.utils.Log_OC private const val TAG = "UserAccountManagerExtensions" +@Suppress("TooGenericExceptionCaught", "ReturnCount") fun UserAccountManager.createOwncloudClient(): OwnCloudClient? { val context = context ?: MainApp.getAppContext() if (context == null) { From 965524179ce9cc652fc9fedf045547f8b93a4b8b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 09:36:10 +0200 Subject: [PATCH 11/12] add tests Signed-off-by: alperozturk96 --- .../UserAccountManagerExtensions.kt | 18 ++++--- .../UserAccountManagerExtensionsTest.kt | 47 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/utils/extensions/UserAccountManagerExtensionsTest.kt diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt index ffe015414f23..51cffc0a7de2 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/UserAccountManagerExtensions.kt @@ -11,28 +11,34 @@ import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.MainApp 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.utils.Log_OC private const val TAG = "UserAccountManagerExtensions" -@Suppress("TooGenericExceptionCaught", "ReturnCount") -fun UserAccountManager.createOwncloudClient(): OwnCloudClient? { +fun UserAccountManager.createOwncloudClient(): OwnCloudClient? = createOwncloudClient(currentAccount.name) + +@Suppress("TooGenericExceptionCaught", "ReturnCount", "DEPRECATION") +fun UserAccountManager.createOwncloudClient(accountName: String): OwnCloudClient? { val context = context ?: MainApp.getAppContext() if (context == null) { Log_OC.e(TAG, "app context is null, cannot create client") return null } - val account = currentAccount - if (account.isAnonymous(context)) { - Log_OC.e(TAG, "current account is anonymous, cannot create client") + val user = getUser(accountName).orElse(null) + if (user == null || user.isAnonymous) { + Log_OC.e(TAG, "account is not registered, cannot create client for: $accountName") return null } return try { - val result = OwnCloudClientFactory.createOwnCloudClient(account, context) + val result = OwnCloudClientFactory.createOwnCloudClient(user.toPlatformAccount(), context) Log_OC.i(TAG, "client created") result + } catch (e: AccountUtils.AccountNotFoundException) { + Log_OC.e(TAG, "account removed while creating client for: $accountName", e) + null } catch (e: Exception) { Log_OC.e(TAG, "cannot create client: ", e) null diff --git a/app/src/test/java/com/nextcloud/utils/extensions/UserAccountManagerExtensionsTest.kt b/app/src/test/java/com/nextcloud/utils/extensions/UserAccountManagerExtensionsTest.kt new file mode 100644 index 000000000000..ddd9f01d3669 --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/extensions/UserAccountManagerExtensionsTest.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.content.Context +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional + +class UserAccountManagerExtensionsTest { + + private val accountName = "test@server.com" + + private lateinit var accountManager: UserAccountManager + + @Before + fun setUp() { + accountManager = mock() + whenever(accountManager.context).thenReturn(mock()) + } + + @Test + fun `no client is created for an unknown account`() { + whenever(accountManager.getUser(accountName)).thenReturn(Optional.empty()) + + assertNull(accountManager.createOwncloudClient(accountName)) + } + + @Test + fun `no client is created while the account has no base url yet`() { + val anonymousUser = mock() + whenever(anonymousUser.isAnonymous).thenReturn(true) + whenever(accountManager.getUser(accountName)).thenReturn(Optional.of(anonymousUser)) + + assertNull(accountManager.createOwncloudClient(accountName)) + } +} From 01f36687e80768bba3009da8ed666e2bfc68261e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 10:04:03 +0200 Subject: [PATCH 12/12] add tests Signed-off-by: alperozturk96 --- .../UserAccountManagerExtensionsTest.kt | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/nextcloud/utils/extensions/UserAccountManagerExtensionsTest.kt b/app/src/test/java/com/nextcloud/utils/extensions/UserAccountManagerExtensionsTest.kt index ddd9f01d3669..336a02157f79 100644 --- a/app/src/test/java/com/nextcloud/utils/extensions/UserAccountManagerExtensionsTest.kt +++ b/app/src/test/java/com/nextcloud/utils/extensions/UserAccountManagerExtensionsTest.kt @@ -7,12 +7,20 @@ package com.nextcloud.utils.extensions +import android.accounts.Account import android.content.Context import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.accounts.AccountUtils +import org.junit.After import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Before import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.util.Optional @@ -22,11 +30,43 @@ class UserAccountManagerExtensionsTest { private val accountName = "test@server.com" private lateinit var accountManager: UserAccountManager + private lateinit var context: Context + private lateinit var platformAccount: Account + private lateinit var client: OwnCloudClient + private lateinit var clientFactory: MockedStatic @Before fun setUp() { + context = mock() + platformAccount = mock() + client = mock() + accountManager = mock() - whenever(accountManager.context).thenReturn(mock()) + whenever(accountManager.context).thenReturn(context) + + clientFactory = Mockito.mockStatic(OwnCloudClientFactory::class.java) + } + + @After + fun tearDown() { + clientFactory.close() + } + + @Test + fun `client is created for a registered account`() { + givenRegisteredAccount() + givenClientIsCreatedFor(platformAccount, context) + + assertSame(client, accountManager.createOwncloudClient(accountName)) + } + + @Test + fun `client is created for the current account when no account name is given`() { + whenever(accountManager.currentAccount).thenReturn(accountNamed(accountName)) + givenRegisteredAccount() + givenClientIsCreatedFor(platformAccount, context) + + assertSame(client, accountManager.createOwncloudClient()) } @Test @@ -34,6 +74,8 @@ class UserAccountManagerExtensionsTest { whenever(accountManager.getUser(accountName)).thenReturn(Optional.empty()) assertNull(accountManager.createOwncloudClient(accountName)) + + clientFactory.verifyNoInteractions() } @Test @@ -43,5 +85,36 @@ class UserAccountManagerExtensionsTest { whenever(accountManager.getUser(accountName)).thenReturn(Optional.of(anonymousUser)) assertNull(accountManager.createOwncloudClient(accountName)) + + clientFactory.verifyNoInteractions() + } + + @Test + fun `no client is created when the account is removed while the client is created`() { + givenRegisteredAccount() + clientFactory.`when` { + OwnCloudClientFactory.createOwnCloudClient(platformAccount, context) + }.thenThrow(AccountUtils.AccountNotFoundException(platformAccount, "Account not found", null)) + + assertNull(accountManager.createOwncloudClient(accountName)) + } + + private fun givenClientIsCreatedFor(account: Account, appContext: Context) { + clientFactory.`when` { + OwnCloudClientFactory.createOwnCloudClient(account, appContext) + }.thenReturn(client) + } + + // Account.name is a public final field, so it cannot be stubbed and has to be written directly + private fun accountNamed(name: String): Account = mock().also { account -> + Account::class.java.getField("name").apply { isAccessible = true }.set(account, name) + } + + @Suppress("DEPRECATION") + private fun givenRegisteredAccount() { + val user = mock() + whenever(user.isAnonymous).thenReturn(false) + whenever(user.toPlatformAccount()).thenReturn(platformAccount) + whenever(accountManager.getUser(accountName)).thenReturn(Optional.of(user)) } }